Lives anywhere.
Hears everything.

One database from the screen to the server: state your screens follow live, a replica that syncs with your server, a server that usually needs no cache in front of it, a file per tenant in every region, and search built in.

Get started Try it in the browser

State your screens follow

Keep the app's state in the database and give each screen a live query. A write runs again every query that reads what it wrote, and the screen gets the new rows; nothing calls a redraw. It is for state that outlives a keystroke: keep a text box's typing in the component, and write when the user saves.

Running a query again is the query itself: a page of 50 open todos out of 1 000 to 3 000, ordered by an index, took 0.19 ms, and a write no live query reads costs about 2 µs. State in the page and live queries in Swift, Kotlin and Dart

Local and server, one flow

Open the database on the device as a replica of the rows it needs. Reads never wait for the network. A write shows at once and waits in a queue inside the same file until the server can be reached; then it is sent once, under its Idempotency-Key, and reaches every other screen that follows those rows. The server owns the data: a write it refuses is put back, and there is no CRDT and no merging.

The browser's sync and the native one in Swift, Kotlin and Dart are held to the same 71 scenarios: offline writes, retries, refusals, dropped streams, restarts. A server following your PostgreSQL hands a commit there to its subscribers 0.09 ms later at the median. Sync, sync on a phone and following PostgreSQL

Many writers, one file

One write runs at a time, and that costs little: a write holds the lock for microseconds, and its fsync runs after the lock is let go, so writers waiting on the disk share one. Readers take a shared lock and run beside them.

Durable writes, 1 writer to 16
252 → 516/sSQLite 248 → 258
Rows read beside four writers
701 000/sSQLite 344 000

Durable writes, an fsync each, and four reading threads, in one process on an M1. A block of 1 000 writes builds its rows beside the readers and holds them for the 0.15 ms it lands. Method and the rest of the table

Traffic that needs no cache

The operating system keeps the pages of the file in memory, and a read decodes the row straight from them. fenecdb copies nothing in between, so there is no buffer pool of its own to warm, evict or keep in step with the database, and a separate cache in front of the server is usually unnecessary while the working set fits in memory.

A row by id over HTTP
0.022 msmedian, one client
Rows by id, eight clients at once
149 268/s
YCSB C, all reads, 16 clients, buffered
18.4 k ops/sp99 2.9 ms; Redis 14.0 k, p99 4.0 ms; both in Docker
YCSB B over HTTP, 16 clients, durable
18.2 k ops/sRedis 9.25 k, PostgreSQL 13.0 k, both in Docker; every cell
Cells where Redis did more operations a second
none of 33the same durability mode against the same

The first two figures are over 10 000 rows on loopback, an M1, one POST /query a request on a kept-alive connection (method). The YCSB figures are one node, 1 000 000 records of 1 KB, fenec-server and Redis 7.4 in Docker (every cell).

In Docker a read by key spends 0.023 ms inside the server of a 0.288 ms round trip and the rest is the network, so over the same network a cache in front can take off at most the time spent inside the server, and brings an invalidation to keep right. A cache nearer the app than the database is not measured. This holds while the working set fits in memory: past it a read waits for the disk, as in any database (before the file outgrows the page cache). It is about the database’s reads, not an HTTP or CDN response cache. The figures and their conditions and why there is no buffer pool of its own

Scale by tenant

Give each tenant a file of its own. Nodes hold the files, a router sends each request to its tenant's node, and each tenant has a copy that follows it on another node. With leases the router fails a node over by itself: the node stops writing before its tenants are promoted, so no tenant has two primaries. The limit is plain: one tenant lives whole on one machine.

A 58.7 MB tenant moved
under 0.3 s
Tenants failed over onto a standby
20 in 63.5–79 msthe medians of three rounds
A tenant's copy behind its primary
0.089 msmedian, p99 0.448
Failed over inside a region cut off
1.41–1.70 s10 tenants, a lease of 1 s

Across regions a tenant lives in one, its copy in another datacenter of it, and a region that keeps its own directory fails its nodes over by itself while cut off from the rest. One URL reaches every region, and a read replica near a far client answers its reads, never from before its own write. Tenants and sharding, failover on a lease, regions, one URL for every region, read replicas near the client, replication and a kit to deploy them

Security

Every request passes the same checks, from the login to the disk.

  • Sign in safelyA token for the server, a read-only token for a dashboard. Repeated failures slow down.
  • Your sign-in providerTokens from Auth0, Clerk or Firebase work as they are. Keys rotate without a restart.
  • Each user, their own rowsEveryone sees only the rows they may; writing someone else's is refused.
  • A record of who did whatEvery refusal, schema change and admin action is written to a log.
  • Encrypted backupsBackups and archives are sealed with a key you hold, and so are copies kept in the browser.
  • Nothing borrowedNo third-party code in the engine or the server: nothing upstream to compromise.

Your language

Every language reaches the server over plain HTTP and JSON, and Swift, Kotlin and Dart can also keep the database in the app. Your data from PostgreSQL or SQLite and your AI framework come with it. Each session is the same flow against fenec-server: make a collection, write two rows, read them back by a filter and an order.


      
  1. from fenecdb import Client
    
    db = Client("http://127.0.0.1:8080", token="secret")
    db.query("create collection if not exists docs (title text @text, year int @sorted)")
    db.query("put docs {title: $1, year: $2}", ["Dunes", 2021])
    db.query("put docs {title: $1, year: $2}", ["Night at the oasis", 2024])
    rows = db.query("get docs select title, year where year >= $1 order year desc limit $2", [2020, 5])
  2. import { connect } from '@fenecdb/web';
    
    const db = connect('http://127.0.0.1:8080', { token: 'secret' });
    await db.run('create collection if not exists docs (title text @text, year int @sorted)');
    await db.from('docs').insert({ title: 'Dunes', year: 2021 });
    await db.from('docs').insert({ title: 'Night at the oasis', year: 2024 });
    const rows = await db.from('docs').select('title', 'year').where('year', '>=', 2020).order('year', 'desc').limit(5).rows();
  3. import fenecdb "github.com/fenecdb/fenecdb/integrations/go"
    
    db := fenecdb.New("http://127.0.0.1:8080", fenecdb.WithToken("secret"))
    db.Exec(ctx, "create collection if not exists docs (title text @text, year int @sorted)")
    db.Exec(ctx, "put docs {title: $1, year: $2}", "Dunes", 2021)
    db.Exec(ctx, "put docs {title: $1, year: $2}", "Night at the oasis", 2024)
    rows, err := db.Query(ctx, "get docs select title, year where year >= $1 order year desc limit $2", 2020, 5)
  4. using FenecDb;
    
    using var db = new FenecClient("http://127.0.0.1:8080", new() { Token = "secret" });
    await db.ExecAsync("create collection if not exists docs (title text @text, year int @sorted)");
    await db.ExecAsync("put docs {title: $1, year: $2}", ["Dunes", 2021]);
    await db.ExecAsync("put docs {title: $1, year: $2}", ["Night at the oasis", 2024]);
    var rows = await db.QueryAsync("get docs select title, year where year >= $1 order year desc limit $2", [2020, 5]);
  5. use serde_json::{json, Value};
    
    fn query(q: &str, params: Value) -> Result<Value, ureq::Error> {
        Ok(ureq::post("http://127.0.0.1:8080/query")
            .set("Authorization", "Bearer secret")
            .send_json(json!({"query": q, "params": params}))?
            .into_json()?)
    }
    query("create collection if not exists docs (title text @text, year int @sorted)", json!([]))?;
    query("put docs {title: $1, year: $2}", json!(["Dunes", 2021]))?;
    query("put docs {title: $1, year: $2}", json!(["Night at the oasis", 2024]))?;
    let rows = query("get docs select title, year where year >= $1 order year desc limit $2", json!([2020, 5]))?;
  6. static HttpResponse<String> query(String json) throws Exception {
        var req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:8080/query"))
            .header("Content-Type", "application/json")
            .header("Authorization", "Bearer secret")
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();
        return HTTP.send(req, HttpResponse.BodyHandlers.ofString());
    }
    query("""
        {"query": "create collection if not exists docs (title text @text, year int @sorted)"}""");
    query("""
        {"query": "put docs {title: $1, year: $2}", "params": ["Dunes", 2021]}""");
    query("""
        {"query": "put docs {title: $1, year: $2}", "params": ["Night at the oasis", 2024]}""");
    String rows = query("""
        {"query": "get docs select title, year where year >= $1 order year desc limit $2", "params": [2020, 5]}""").body();
  7. function query(string $q, array $params = []) {
        $ch = curl_init('http://127.0.0.1:8080/query');
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer secret'],
            CURLOPT_POSTFIELDS => json_encode(['query' => $q, 'params' => $params]),
        ]);
        $body = json_decode(curl_exec($ch), true);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        if ($status >= 300) {
            throw new RuntimeException($body['error'], $status);
        }
        return $body;
    }
    query('create collection if not exists docs (title text @text, year int @sorted)');
    query('put docs {title: $1, year: $2}', ['Dunes', 2021]);
    query('put docs {title: $1, year: $2}', ['Night at the oasis', 2024]);
    $rows = query('get docs select title, year where year >= $1 order year desc limit $2', [2020, 5]);
  8. def query(q, params = [])
      req = Net::HTTP::Post.new(URI.join(URL, "/query"), "Content-Type" => "application/json")
      req["Authorization"] = "Bearer secret"
      req.body = JSON.generate(query: q, params: params)
      res = Net::HTTP.start(URL.host, URL.port) { |http| http.request(req) }
      body = JSON.parse(res.body)
      raise "#{res.code}: #{body["error"]}" unless res.is_a?(Net::HTTPSuccess)
      body
    end
    query("create collection if not exists docs (title text @text, year int @sorted)")
    query("put docs {title: $1, year: $2}", ["Dunes", 2021])
    query("put docs {title: $1, year: $2}", ["Night at the oasis", 2024])
    rows = query("get docs select title, year where year >= $1 order year desc limit $2", [2020, 5])
  9. import FenecDB
    
    // Or Fenec.open(path: path): the database in a file the app holds.
    let db = Fenec.connect(url: "http://127.0.0.1:8080", token: "secret")
    try await db.execute("create collection if not exists docs (title text @text, year int @sorted)")
    try await db.execute("put docs {title: $1, year: $2}", "Dunes", 2021)
    try await db.execute("put docs {title: $1, year: $2}", "Night at the oasis", 2024)
    let rows = try await db.query("get docs select title, year where year >= $1 order year desc limit $2", 2020, 5)
  10. import com.fenecdb.*
    
    // Or Fenec.openAsync(path): the database in a file the app holds.
    val db = Fenec.connect("http://127.0.0.1:8080", token = "secret")
    db.execute("create collection if not exists docs (title text @text, year int @sorted)")
    db.execute("put docs {title: \$1, year: \$2}", "Dunes", 2021)
    db.execute("put docs {title: \$1, year: \$2}", "Night at the oasis", 2024)
    val rows = db.query("get docs select title, year where year >= \$1 order year desc limit \$2", 2020, 5)
  11. import 'package:fenecdb/fenecdb.dart';
    
    // Or Fenec.open(path): the database in a file the app holds.
    final db = Fenec.connect('http://127.0.0.1:8080', token: 'secret');
    await db.execute('create collection if not exists docs (title text @text, year int @sorted)');
    await db.execute(r'put docs {title: $1, year: $2}', ['Dunes', 2021]);
    await db.execute(r'put docs {title: $1, year: $2}', ['Night at the oasis', 2024]);
    final rows = await db.query(r'get docs select title, year where year >= $1 order year desc limit $2', [2020, 5]);
  12. $ fenec-server --file app.fenec --http 127.0.0.1:8080 --http-token secret
    $ curl -X POST localhost:8080/query -H 'authorization: Bearer secret' \
      -d '{"query":"create collection docs (title text @text, year int @sorted)"}'
    {"message":"collection `docs` created"}
    $ curl -X POST localhost:8080/query -H 'authorization: Bearer secret' \
      -d '{"query":"put docs {title: $1, year: $2}","params":["Dunes",2021]}'
    {"affected":1}
    $ curl -X POST localhost:8080/query -H 'authorization: Bearer secret' \
      -d '{"query":"put docs {title: $1, year: $2}","params":["Night at the oasis",2024]}'
    {"affected":1}
    $ curl -X POST localhost:8080/query -H 'authorization: Bearer secret' \
      -d '{"query":"get docs select title, year where year >= $1 order year desc limit $2","params":[2020,5]}'
    [{"title":"Night at the oasis","year":2024},{"title":"Dunes","year":2021}]
  13. $ fenec import data.sqlite --table docs --into articles --index "category@hash" --dry-run
    source   data.sqlite -> docs
    target   articles.fenec -> articles
    
    create collection articles (
      title      text           <- text
      category   text           <- text
      score      int            <- int
      published  timestamp      <- datetime
    )
      id  <- id (INTEGER PRIMARY KEY)
    create index on articles (category) @hash
    
    nothing was written (--dry-run); use --count for the row count
  14. $ fenec import postgres://app@db:5432/app --table docs --into articles \
        --index "category@hash" --follow
    source   app@db:5432/app -> docs
    target   articles.fenec -> articles
    follow   slot fenec_articles (created), publication fenec_articles (created)
    note     the slot keeps the server's WAL until this follower confirms it; to stop following for good: select pg_drop_replication_slot('fenec_articles')
    copying  there is none yet
    100000 records, articles.fenec (339.37ms)
    following docs: ctrl-c stops, and the same command resumes
    42 transactions, 57 changes, at 0/2BEA458
  15. from fenecdb.langchain import FenecVectorStore
    store = FenecVectorStore(
        embeddings, "handbook",
        url="http://127.0.0.1:8080", token="...",
        metadata_fields={"source": "text", "year": "int"},
    )
    store.add_documents(documents)
    store.similarity_search("how do I compact", k=4, filter={"source": "ops"})
  16. from fenecdb.llama_index import FenecVectorStore
    from llama_index.core import StorageContext, VectorStoreIndex
    store = FenecVectorStore("handbook", url="http://127.0.0.1:8080", token="...",
                             metadata_fields={"author": "text", "year": "int"})
    index = VectorStoreIndex.from_documents(
        documents, storage_context=StorageContext.from_defaults(vector_store=store)
    )
    index.as_retriever(similarity_top_k=4).retrieve("how do I compact")
  17. import { connect } from '@fenecdb/web';
    import { FenecVectorStore } from '@fenecdb/langchain';
    const db = connect('http://127.0.0.1:8080');
    const store = new FenecVectorStore(embeddings, {
      client: db,
      collection: 'handbook',
      metadataFields: { source: 'text' },
      fullText: true,
    });
    await store.addDocuments(documents);
    await store.search('compact', 4, { mode: 'hybrid' });
  18. import { generateText, stepCountIs } from 'ai';
    import { openai } from '@ai-sdk/openai';
    import { connect } from '@fenecdb/web';
    import { index, searchTool } from './rag.js';   // integrations/ai-sdk/rag.js
    const db = connect('http://127.0.0.1:8080', { token: 'secret' });
    const model = openai.textEmbeddingModel('text-embedding-3-small');
    await index(db, { collection: 'docs', model, documents });
    
    const { text } = await generateText({
      model: openai('gpt-4.1'),
      tools: { search: searchTool(db, { collection: 'docs', model }) },
      stopWhen: stepCountIs(4),
      prompt: 'How do I compact the database?',
    });

Each language's example runs against a real server on every change, and Python, JavaScript, Go and .NET have official clients. Examples in your language, the database in an app, every integration and import

A first query in a few commands

Open it in a web page from npm, keep it in an app's file, or start the server from the Docker image or a release and send it a request from the language you already use. Or clone one small app, the same in every language, and run it.

Get started Read the docs See the examples