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.
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
Search, built in
Mark a text @text and match
ranks it by BM25. fuzzy finds a word typed wrong and
prefix one half typed, as a search box sends them;
highlight() and snippet() mark what matched,
and facet counts each brand or colour over every match for
the sidebar, in the same query. A vector field adds search by meaning
with near, and fuse puts the two rankings
together. It is the same engine in a browser tab, on a phone and on the
server, and in the page it is 330 KB of gzipped WebAssembly with no
dependencies.
- A
matchover 100 000 documents - 0.06 mstwo words, the best ten
- Words within a typo, among FiQA’s 135 500
- 14 µsone edit; 120 µs at two
- A facet over every row, through
@hash - 0.01 ms100 000 rows
In one process on an M1. A row’s marks cost
1 to 3 µs more, and with a typo put into each of BEIR SciFact’s
queries, fuzzy takes its nDCG@10 from 0.636 back to 0.663,
where the queries as written score 0.662.
Text search,
typos and words begun,
facets,
words and meaning together and
how good the answers
are; fenecdb beside
PostgreSQL,
SQLite and
PGlite. The
playground runs the same engine in this
tab, with fenec studio over it.
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.
title year Night at the oasis 2024 Dunes 2021
-
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])
-
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();
-
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)
-
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]);
-
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]))?;
-
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();
-
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]);
-
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])
-
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)
-
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)
-
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]);
-
$ 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}]
-
$ 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
-
$ 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 -
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"}) -
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")
-
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' });
-
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