An embedded key-value database engine for Go. Think SQLite, but for JSON/key-value data instead of SQL tables.
No server. No network. No Docker. Just a Go library you import into your application.
db, _ := engine.Open("app.db")
defer db.Close()
db.Set("user:1", engine.NewObject(map[string]engine.Value{
"name": engine.NewString("Mehul"),
"age": engine.NewNumber(20),
}))
val, _ := db.Get("user:1")Your data lives in a dora/ directory right next to your application -- like prisma/ or drizzle/, but for your database. Open it, read it, write it, close it. That's it.
go get github.com/MehulxBuilds/doraimport "github.com/MehulxBuilds/dora/engine"Requires Go 1.24+. Zero external dependencies -- only the Go standard library.
// Persistent -- files created at dora/myapp.db and dora/myapp.db.wal
db, err := engine.Open("myapp.db")
// In-memory -- no files, data lost when process exits
db, err := engine.Open("")Open("") gives you a fast in-memory database. Open("myapp.db") gives you a persistent database. All files are automatically placed inside a dora/ directory (created if it doesn't exist).
// Store a string
db.Set("greeting", engine.NewString("hello world"))
// Store a number
db.Set("counter", engine.NewNumber(42))
// Store a JSON-like object
db.Set("user:1", engine.NewObject(map[string]engine.Value{
"name": engine.NewString("Mehul"),
"age": engine.NewNumber(20),
"admin": engine.NewBoolean(true),
}))
// Read it back
val, err := db.Get("user:1")
if err != nil {
// engine.ErrKeyNotFound if the key doesn't exist
}
obj, _ := val.Object()
name, _ := obj["name"].String()
fmt.Println(name) // "Mehul"err := db.Delete("user:1")
// Returns engine.ErrKeyNotFound if the key doesn't existif db.Exists("user:1") {
// ...
}fmt.Println(db.Len()) // number of keys in the databaseerr := db.Close()Always call Close() when you're done. For persistent databases, this writes the final state to disk. If you forget, individual operations are still safe in the WAL (explained below), but the snapshot file won't be up to date.
Dora doesn't store raw bytes or plain strings. It has a typed value system that mirrors JSON:
| Type | Constructor | Example |
|---|---|---|
| Null | NewNull() |
null |
| Boolean | NewBoolean(true) |
true, false |
| Number | NewNumber(3.14) |
42, 3.14, -1 |
| String | NewString("hello") |
"hello world" |
| Array | NewArray([]Value{...}) |
[1, "two", true] |
| Object | NewObject(map[string]Value{...}) |
{"name": "Mehul"} |
Every accessor returns the value and a boolean indicating whether the type matched:
val, _ := db.Get("counter")
n, ok := val.Number() // n = 42.0, ok = true
s, ok := val.String() // s = "", ok = false (it's a Number, not a String)
if val.IsNull() {
// handle null
}Values can be nested to any depth:
db.Set("config", engine.NewObject(map[string]engine.Value{
"server": engine.NewObject(map[string]engine.Value{
"host": engine.NewString("localhost"),
"port": engine.NewNumber(8080),
"tags": engine.NewArray([]engine.Value{
engine.NewString("production"),
engine.NewString("us-east"),
}),
}),
}))Once you create a value, it can't be changed. Constructors copy their input (slices and maps), and accessors return copies. This means you can safely pass values between goroutines without worrying about data races.
Keys are just strings. You can use any naming convention you want, but a common pattern is type:id:
user:1
user:2
user:3
post:1
post:2
settings:theme
Dora gives you two ways to query by prefix:
// Get all keys that start with "user:"
userKeys := db.Keys("user:")
// ["user:1", "user:2", "user:3"]
// Get all keys (no prefix filter)
allKeys := db.Keys()// Get all users as a map
users := db.Prefix("user:")
// map[Key]Value{
// "user:1": ...,
// "user:2": ...,
// "user:3": ...,
// }
for key, val := range users {
obj, _ := val.Object()
name, _ := obj["name"].String()
fmt.Printf("%s: %s\n", key, name)
}The returned map is a copy. You can read and iterate it freely without affecting the database.
A transaction groups multiple operations into a single atomic unit. Either all operations succeed, or none of them do.
err := db.Transaction(func(tx *engine.Tx) error {
tx.Set("user:1", engine.NewObject(map[string]engine.Value{
"name": engine.NewString("Mehul"),
"balance": engine.NewNumber(100),
}))
tx.Set("user:2", engine.NewObject(map[string]engine.Value{
"name": engine.NewString("Alice"),
"balance": engine.NewNumber(200),
}))
return nil // commit -- both users are saved
})If the function returns an error, everything is discarded:
err := db.Transaction(func(tx *engine.Tx) error {
tx.Set("user:1", engine.NewObject(map[string]engine.Value{
"name": engine.NewString("Mehul"),
}))
// Something went wrong
return fmt.Errorf("validation failed")
// user:1 is NOT saved -- rollback
})Inside a transaction, you can read values you just wrote:
db.Transaction(func(tx *engine.Tx) error {
tx.Set("counter", engine.NewNumber(1))
val, _ := tx.Get("counter")
n, _ := val.Number()
// n == 1 -- you see your own uncommitted write
tx.Set("counter", engine.NewNumber(n + 1))
return nil
})Inside the transaction function, always use tx.Set, tx.Get, tx.Delete, tx.Exists. Do NOT call db.Set, db.Get, etc. -- this will deadlock because the transaction holds an exclusive lock.
When you open a database with a file path, Dora creates two files inside the dora/ directory:
| File | Purpose |
|---|---|
dora/app.db |
Snapshot -- the full database state as human-readable JSON |
dora/app.db.wal |
Write-ahead log -- a journal of recent operations |
When you call db.Set(...):
- The operation is written to the WAL file and fsynced to disk.
- The operation is applied to the in-memory state.
The write-ahead log is the key to durability. Once step 1 completes, the operation survives a crash -- even if the process dies before step 2. On the next Open(), the WAL is replayed to recover any operations that weren't captured in the snapshot.
The snapshot file (dora/app.db) is a point-in-time copy of the entire database, written as pretty-printed JSON:
{
"user:1": {
"name": "Mehul",
"age": 20
},
"counter": 42
}The snapshot is updated when you call Close() or Snapshot(). Between those calls, the WAL holds the operations that are ahead of the snapshot.
If your process crashes:
1. Open("app.db")
2. Load the snapshot (dora/app.db)
3. Replay the WAL (dora/app.db.wal) -- apply any operations added since the last snapshot
4. Database is recovered to the exact state before the crash
At most one operation can be lost -- the one that was being written at the exact moment of the crash. All operations that completed (fsync returned successfully) are guaranteed to survive.
For long-running applications, the WAL can grow large. Call Snapshot() periodically to write the current state and clear the WAL:
// Take a snapshot every 5 minutes
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
db.Snapshot()
}
}()After a snapshot, recovery is instant -- the WAL is empty and the snapshot file has everything.
Dora is safe to use from multiple goroutines.
- Reads (
Get,Exists,Len,Keys,Prefix,Seq,StateHash) can run concurrently with each other. - Writes (
Set,Delete) are exclusive -- only one write at a time. - Transactions hold an exclusive lock for their entire duration.
// Safe -- multiple goroutines can read concurrently
go func() { db.Get("key") }()
go func() { db.Get("key") }()
go func() { db.Exists("key") }()
// Safe -- writes are serialized automatically
go func() { db.Set("a", engine.NewNumber(1)) }()
go func() { db.Set("b", engine.NewNumber(2)) }()You don't need to add your own locks. The database handles all synchronization internally.
Once you call Close(), all methods return errors (or zero values):
db.Close()
err := db.Set("key", engine.NewString("value"))
// err == engine.ErrClosed
_, err = db.Get("key")
// err == engine.ErrClosed
db.Exists("key") // false
db.Len() // 0
db.Keys() // nilEvery mutation increments a sequence number. You can use this, combined with the state hash, to verify that two databases are in the same state.
db.Set("a", engine.NewNumber(1)) // seq = 1
db.Set("b", engine.NewNumber(2)) // seq = 2
db.Delete("a") // seq = 3
fmt.Println(db.Seq()) // 3The sequence number is persisted across restarts.
hash, err := db.StateHash()
// hash is a [32]byte -- SHA-256 of the entire database contentsThe hash is deterministic: two databases with the same key-value data always produce the same hash, regardless of how they got there. The hash does not include the sequence number -- it's purely a function of the data.
db1, _ := engine.Open("")
db2, _ := engine.Open("")
db1.Set("x", engine.NewNumber(1))
db1.Set("y", engine.NewNumber(2))
db2.Set("x", engine.NewNumber(1))
db2.Set("y", engine.NewNumber(2))
h1, _ := db1.StateHash()
h2, _ := db2.StateHash()
fmt.Println(h1 == h2) // true
fmt.Println(db1.Seq() == db2.Seq()) // trueSame operations in the same order produce the same sequence number and the same state hash. This is the core determinism guarantee.
Dora uses sentinel errors that you can check with errors.Is:
import "errors"
val, err := db.Get("nonexistent")
if errors.Is(err, engine.ErrKeyNotFound) {
// key doesn't exist
}
err = db.Set("", engine.NewString("value"))
if errors.Is(err, engine.ErrKeyEmpty) {
// empty keys are not allowed
}
db.Close()
err = db.Set("key", engine.NewString("value"))
if errors.Is(err, engine.ErrClosed) {
// database is closed
}| Error | When |
|---|---|
ErrKeyNotFound |
Get or Delete on a key that doesn't exist |
ErrKeyEmpty |
Any operation with an empty string key |
ErrClosed |
Any operation after Close() has been called |
func Open(path string) (*DB, error) // open or create a database
func (db *DB) Close() error // save state and close
func (db *DB) Snapshot() error // save state without closingfunc (db *DB) Set(key Key, val Value) error // create or overwrite
func (db *DB) Get(key Key) (Value, error) // read a value
func (db *DB) Delete(key Key) error // remove a key
func (db *DB) Exists(key Key) bool // check existence
func (db *DB) Len() int // count keys
func (db *DB) Keys(prefix ...string) []Key // list keys (optional prefix filter)
func (db *DB) Prefix(prefix string) map[Key]Value // key-value pairs by prefixfunc (db *DB) Transaction(fn func(tx *Tx) error) error
func (tx *Tx) Set(key Key, val Value) error
func (tx *Tx) Get(key Key) (Value, error)
func (tx *Tx) Delete(key Key) error
func (tx *Tx) Exists(key Key) boolfunc (db *DB) Seq() uint64 // current operation sequence number
func (db *DB) StateHash() ([32]byte, error) // SHA-256 of all datafunc NewNull() Value
func NewBoolean(b bool) Value
func NewNumber(n float64) Value
func NewString(s string) Value
func NewArray(items []Value) Value
func NewObject(fields map[string]Value) Valuefunc (v Value) Type() ValueType
func (v Value) IsNull() bool
func (v Value) Boolean() (bool, bool)
func (v Value) Number() (float64, bool)
func (v Value) String() (string, bool)
func (v Value) Array() ([]Value, bool)
func (v Value) Object() (map[string]Value, bool)Your Go Application
|
engine.Open("app.db")
|
v
+----DB----+ (public API, locking)
| |
| state | (in-memory key-value map)
| |
+----+-----+
|
+----+-----+-----+
| | |
WAL Storage Tx
| |
dora/ (all files live here)
|- app.db.wal
|- app.db
DB is the public API. It holds the lock, validates input, coordinates the WAL and storage, and delegates to the state.
State is a plain Go map (map[Key]Value) with a sequence counter. It has no locks, no I/O, no side effects. It's a pure data structure. All reads and writes go through the DB, which holds the appropriate lock.
WAL (write-ahead log) is a file where every mutation is recorded before it's applied to state. This is what makes the database durable. The WAL uses JSON Lines format -- one JSON object per line, human-readable, easy to inspect:
{"type":"SET","key":"user:1","value":{"name":"Mehul","age":20}}
{"type":"DELETE","key":"temp:session"}
{"type":"TX","ops":[{"type":"SET","key":"a","value":1},{"type":"SET","key":"b","value":2}]}
Storage is an interface with Load, Save, and Close methods. The default implementation (jsonStorage) writes human-readable JSON files with atomic writes (write to temp file, then rename). The interface is there so a future binary storage engine can be swapped in without changing the rest of the codebase.
Tx (transaction) buffers mutations in an overlay map. On commit, all operations are written to the WAL as a single atomic entry and then applied to state. On rollback, the overlay is discarded.
Every mutation -- whether from db.Set, db.Delete, or a transaction -- goes through the same path:
Validate key
|
Create Operation (SET or DELETE)
|
Write to WAL (fsync) <-- durable after this point
|
Apply to state <-- visible after this point
|
Increment sequence number
This is why the database is a deterministic state machine. The same sequence of operations, starting from the same initial state, always produces the same final state and the same state hash.
| In-Memory | Persistent | |
|---|---|---|
| Open | Open("") |
Open("app.db") |
| Files | None | dora/app.db + dora/app.db.wal |
| Snapshot | No-op | Writes dora/app.db |
| Speed | Fastest | Slightly slower (disk fsync) |
| Survives restart | No | Yes |
| Use case | Tests, caches, temp data | Real applications |
Both modes have the same API. You can develop with in-memory and switch to persistent by changing one string.
All database files live inside the dora/ directory:
your-project/
dora/
app.db <-- snapshot (human-readable JSON)
app.db.wal <-- write-ahead log (JSON Lines)
main.go
go.mod
The snapshot file is valid, human-readable JSON. You can open it in any text editor:
{
"counter": 42,
"user:1": {
"age": 20,
"name": "Mehul"
}
}The WAL file uses JSON Lines (one JSON object per line):
{"type":"SET","key":"counter","value":42}
{"type":"SET","key":"user:1","value":{"age":20,"name":"Mehul"}}
Both files are designed to be inspected and debugged by humans. A future binary format may be added for performance, but the JSON format will remain as an option.
MIT