A pure-OCaml SQL engine ā a concept port of SQLite targeting MirageOS unikernels. No C stubs, a fresh on-disk file format, single-writer / multi-reader MVCC with snapshot isolation, and strict typing (not SQLite's manifest typing).
The engine covers a large slice of the SQL surface: CRUD, JOINs, aggregates, subqueries and correlated subqueries, CTEs and recursive CTEs, window functions, views, triggers (BEFORE / AFTER / INSTEAD OF), foreign keys with CASCADE / SET NULL / SET DEFAULT and DEFERRABLE checks, UPSERT, FTS5 with BM25 and snippet(), generated columns, partial / expression indexes, SAVEPOINTs, a WAL with crash recovery, VACUUM, overflow pages, and WITHOUT ROWID tables.
Opt-in, page-level AES-256-GCM encryption (#84). Pass a 32-byte raw key to open_file / open_file_wal (or the lower-level Store.open_block*) and the database is created ā or reopened ā encrypted; omit the key and the database is plaintext, exactly as before (encryption is off by default).
let key = (* 32 raw bytes from your secrets manager / boot config *) in
Granary_unix.Store.open_file ~key ~path:"app.db" ()Encryption_key_required); a wrong key is rejected by a header canary (Encryption_key_mismatch); a key supplied for a plaintext database is refused (Not_encrypted).mirage-crypto-rng at boot (the Unix backend uses Mirage_crypto_rng_unix.use_default ()); the core library never seeds, to stay Mirage-clean.PRAGMA synchronous)granary supports a per-deployment durability setting analogous to SQLite's synchronous, gating only the WAL group-commit fsync. CoW shadow-paging, snapshot isolation, rollback, and crash recovery are unaffected ā only when commits are fsynced changes.
Mode | Commit-time fsync | App-process crash | OS / power crash |
|---|---|---|---|
| fsync on every group-commit before the commit is acked | no loss | no loss |
| deferred: fsync once | no loss | up to the last synced commit frame (prefix only) |
| never on commit | no loss | back to the last checkpoint |
Configuration (database-wide):
PRAGMA synchronous = full | batched | offPRAGMA wal_batch_commits = N (default 256) ā the batched commit-count thresholdPRAGMA wal_batch_interval_ms = T (default 100) ā the batched time threshold (milliseconds)Db.open_block ?durability:(Granary_store.Store.Batched { commits; interval_ms }) (also Full / Off)PRAGMA synchronous, PRAGMA wal_batch_commits, PRAGMA wal_batch_interval_ms) read the current values back.Read the database as it existed at any past commit, identified by transaction id or wall-clock timestamp. Feature is off by default ā zero overhead unless enabled.
Pass ~as_of_history:true when opening the database. On the Unix layer this writes a <path>.aslog sidecar file containing a CRC32-verified, fixed-width record per commit (txn id, wall-clock ms, root page):
(* plain-file open *)
let* db = Granary_unix.open_file ~as_of_history:true ~path:"app.db" () in
(* WAL open *)
let* db = Granary_unix.open_file_wal ~as_of_history:true ~path:"app.db" () inFor the lower-level Store.open_block / Store.open_block_wal, supply a ~history:History.sink (the injected log backend) and a ~now clock in addition to ~as_of_history:true.
Pages reachable from historical roots are only retained while a floor is set. Without a floor an open historical reader still pins its own snapshot, but pages from released snapshots can be reclaimed.
(* Retain every root from txn_id onwards ā pages stay queryable. *)
Store.history_pin t ~txn_id;
(* Read the current retention floor (None = unset). *)
Store.history_floor t;
(* Release the floor; reclamation of superseded pages resumes. *)
Store.history_release t;
(* Inspect the full commit log (ascending txn order). *)
let* records = Store.history_log t inThe same wrappers are available at the Db layer (Db.history_pin, Db.history_floor, Db.history_release, Db.history_log).
(* Store level ā raw snapshot *)
let* ro = Store.ro_begin_as_of t (`Txn txn_id) in (* or `Ts ms *)
(* ... get/cursor_open/etc. ... *)
let* () = Store.ro_end ro in
(* SQL level ā lazy result stream *)
let* stream = Db.query_as_of db (`Txn txn_id) "SELECT ā¦" inro_begin_as_of / query_as_of return the committed root with the largest txn id (for `Txn) or timestamp (for `Ts) that is ⤠the target.
Error | Meaning |
|---|---|
| Store opened without |
| Target predates the retained floor (or log is empty) |
|
|
history_pin protects: with no floor set, every as-of target returns History_pruned. Pinning is not retroactive ā pin before the writes you want to retain across.`Ts (timestamp) resolution assumes a monotonic, non-decreasing wall clock (guaranteed under the single-writer model); a backwards clock adjustment could make a `Ts target resolve to a slightly different commit. `Txn resolution is always exact.as-of queries are not supported against attached databases).The engine is 100% OCaml with no C stubs, and the on-disk format is explicitly byte-ordered (big-endian page headers and index keys, little-endian float64 in rows), so builds are architecture-neutral. Both linux/amd64 and linux/arm64 are supported and verified (#157); darwin/arm64 works for local dev. The ocaml/opam base image in Containerfile is published multi-arch, so the same Containerfile builds on either host.
# native build (host architecture)
podman build -t granary-dev -f Containerfile .
# explicit per-arch builds ā these build natively on a matching host and under
# qemu-user-static emulation on a foreign host (e.g. arm64 on an x86 box):
podman build --platform=linux/amd64 -t granary-dev:amd64 -f Containerfile .
podman build --platform=linux/arm64 -t granary-dev:arm64 -f Containerfile .To build or run a foreign-architecture image on an x86 host, register the qemu binfmt handlers once (Debian/Ubuntu):
sudo apt-get install -y qemu-user-static binfmt-supportThen build and test exactly as on the host architecture:
podman run --rm --platform=linux/arm64 -v "$(pwd):/workspace:z" -w /workspace \
granary-dev:arm64 dune runtestA minimal, in-tree sample unikernel under mirage/ runs the engine over a Mirage_block device in WAL mode (the amd64 baseline for the aarch64 audit, #403). It builds and runs on the unix target and builds for the hvt (Solo5) target. The mirage CLI is not in granary-dev; see mirage/README.md for the dedicated build image (Containerfile.mirage) and the build/run commands.
In-process benchmarks against reference C SQLite 3.45.1 (same dataset, prepared statements both sides, WAL, fsync-per-commit, matched page cache), separating the CPU term from the I/O term via cpu/wall per run. Full method and tables: docs/benchmarks/2026-06-07-bench-222-results.md (also on the project wiki, on the maintainer's private development instance).
Current NVMe baseline (1a73da0, after #228 PK B-tree seek, #229 O(n) bulk insert, and the T4/T5 read-path work) ā granary is now within single-digit multiples of C SQLite on most workloads:
workload (NVMe, plaintext) | granary vs SQLite | bound |
|---|---|---|
point lookup | ~3.7Ć slower | CPU |
range scan / aggregate | ~8.3Ć slower | CPU |
insert (autocommit) | ~2.0Ć slower | fsync |
commit throughput | ~2.9Ć slower | fsync |
insert (batch, 1 txn) | ~44Ć slower | mixed |
That is a large improvement over the 2026-06-02 pre-fix baseline (358b2b9), where the same NVMe workloads were ~7,700Ć (point lookup, an O(n) full scan), ~200Ć (scan), and ~5,300Ć (batch insert, an O(n²) path) slower. The point-lookup and bulk-insert complexity bugs are gone; the only large remaining gap is batch insert (~44Ć), a constant-factor copy-on-write write-amplification cost (#230 / #231).
AES-256-GCM encryption-at-rest now adds only ~20% (or within noise) to cache-resident reads ā the frame-cache (T4) caches decrypted pages, down from the ~2Ć (ā +100%) of the pre-fix run. Writes are barely affected.
Verdict: reads are CPU-bound (cpu/wall ā 1.0), single-row writes are fsync/I-O-bound (cpu/wall 0.5ā0.6). With the O(n) read path and O(n²) insert path fixed, 4 of 5 plaintext workloads are within the #231 "10Ć of SQLite" goal; the read-side multicore epic (#156) remains gated on closing the batch-insert constant factor first.
Reproduce: scripts/bench222.sh (builds the bench image and runs the suite; cross-host steps in the results doc).
This codebase is entirely AI-written. Per the avsm/ocaml-ai-disclosure proposal ā which aligns its vocabulary with the W3C AI Content Disclosure levels (none / ai-assisted / ai-generated / autonomous) ā granary's disclosure level is:
ai-generatedAuthorship model. A human (the repository owner) sets the scope, picks which issues to work on, decides architectural trade-offs, and signs off on the result. An AI agent writes all of the code, tests, documentation, and commit messages. The primary model is Claude Opus (Anthropic), with Claude Sonnet occasionally used for cheaper mechanical work.
A handful of commits ā multi-phase autonomous-loop work ā drift toward autonomous, but autonomous would overstate how hands-off the human is at the design and scoping layer, so ai-generated is the honest level for the project as a whole.
The same disclosure is published in the package's opam metadata:
x-ai-disclosure: "ai-generated"
x-ai-model: "claude-opus-4-7"
x-ai-provider: "Anthropic"