HTTP endpoint
Talk to fenecdb with fetch, curl or
any standard library — no driver needed.
This page is the way in: start a server, send it statements, write in batches and retry safely. Every route, the filter syntax, the statuses and the streams are in the HTTP reference, and tokens and policies in HTTP security and scoped access.
Start it
Add --http to fenec-server. Every collection gets
REST routes from its schema, and POST /query takes any FenecQL
statement.
fenec-server --file data.fenec --http 127.0.0.1:8080Then send it a statement. Each example makes a collection of a text and a
number, each indexed, writes two rows, reads them back by a filter and an
order, searches the words with a typo in them and handles a refusal. Each
sends one FenecQL statement a request to POST /query, its values
as $1, $2, ... in params.
curl -s http://127.0.0.1:8080/query \
-d '{"query": "create collection if not exists docs (title text @text, year int @sorted)"}'
# {"message":"collection `docs` created"}
curl -s http://127.0.0.1:8080/query \
-d '{"query": "put docs {title: $1, year: $2}", "params": ["Dunes", 2021]}'
curl -s http://127.0.0.1:8080/query \
-d '{"query": "put docs {title: $1, year: $2}", "params": ["Night at the oasis", 2024]}'
# {"affected":1}
curl -s http://127.0.0.1:8080/query \
-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}]
curl -s http://127.0.0.1:8080/query \
-d '{"query": "get docs select title match title $1 fuzzy", "params": ["oasys"]}'
# [{"title":"Night at the oasis","_score":0.62239116}]
curl -s -w ' %{http_code}\n' http://127.0.0.1:8080/query \
-d '{"query": "get nowhere"}'
# {"error":"collection `nowhere`"} 404pip install fenecdb # the standard library alonefrom fenecdb import Client, FenecError
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])
# [{"title": "Night at the oasis", "year": 2024}, {"title": "Dunes", "year": 2021}]
hits = db.query("get docs select title match title $1 fuzzy", ["oasys"])
# [{"title": "Night at the oasis", "_score": 0.62...}]
try:
db.query("get nowhere")
except FenecError as e:
print(e.status, e) # 404 collection `nowhere`async function query(q: string, params: unknown[] = []) {
const res = await fetch('http://127.0.0.1:8080/query', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer secret' },
body: JSON.stringify({ query: q, params }),
});
const body = await res.json();
if (!res.ok) throw Object.assign(new Error(body.error), { status: res.status });
return body;
}
await query('create collection if not exists docs (title text @text, year int @sorted)');
await query('put docs {title: $1, year: $2}', ['Dunes', 2021]);
await query('put docs {title: $1, year: $2}', ['Night at the oasis', 2024]);
const rows = await query('get docs select title, year where year >= $1 order year desc limit $2', [2020, 5]);
const hits = await query('get docs select title match title $1 fuzzy', ['oasys']);go get github.com/fenecdb/fenecdb/integrations/go # the standard library aloneimport 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)
type Doc struct {
Title string `json:"title"`
Year int `json:"year"`
}
rows, err := fenecdb.QueryAs[Doc](ctx, db, "get docs select title, year where year >= $1 order year desc limit $2", 2020, 5)
hits, err := db.Query(ctx, "get docs select title match title $1 fuzzy", "oasys")
var e *fenecdb.Error
if _, err := db.Query(ctx, "get nowhere"); errors.As(err, &e) {
fmt.Println(e.Status, e.Code, e.Message) // 404 not_found collection `nowhere`
}cargo add ureq@2 --features json
cargo add serde_jsonuse 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]))?;
let hits = query("get docs select title match title $1 fuzzy", json!(["oasys"]))?;
match query("get nowhere", json!([])) {
Err(ureq::Error::Status(404, res)) => { /* res.into_json()? holds {"error": ...} */ }
other => { /* ... */ }
}dotnet add package FenecDb # HttpClient and System.Text.Json aloneusing 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<Doc>("get docs select title, year where year >= $1 order year desc limit $2", [2020, 5]);
var hits = await db.QueryAsync<Hit>("get docs select title match title $1 fuzzy", ["oasys"]);
try { await db.QueryAsync("get nowhere"); }
catch (FenecException e) { Console.WriteLine($"{e.Status} {e.Code} {e.Message}"); } // 404 not_found collection `nowhere`
record Doc(string Title, int Year);
record Hit(string Title, [property: JsonPropertyName("_score")] float Score);static final HttpClient HTTP = HttpClient.newHttpClient();
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();
String hits = query("""
{"query": "get docs select title match title $1 fuzzy", "params": ["oasys"]}""").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]);
$hits = query('get docs select title match title $1 fuzzy', ['oasys']);require "json"
require "net/http"
URL = URI("http://127.0.0.1:8080")
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])
hits = query("get docs select title match title $1 fuzzy", ["oasys"])The programs send the token secret as Authorization:
Bearer, which a server started with --http-token secret
asks for; this one has none set, so it ignores it, and so the
curl examples here leave it out. They assume a fresh file: each
put makes a row with no id, so a second run writes every row
again.
Each language's tab is the start of the same tab on Your language, which the build copies it from, so
the two cannot say different things. make languages-test holds
that page's statements to the programs it runs against a real server, and runs
the curl tab as it is written here. The query builders, batches and
typed errors follow there, and so do Swift, Kotlin and Dart, which keep the
database in the app.
The same rows are on REST routes, one
per collection, with the filter in the
query string. Here for an articles collection of titles, years
and tags:
curl 'http://127.0.0.1:8080/articles?select=title,year&year=gte.2024&tags=has.rust&order=year.desc&limit=10'
# [{"title":"the rust book","year":2024}]
curl 'http://127.0.0.1:8080/articles?year=gte.2024&count'
# {"count":1}
curl -X POST http://127.0.0.1:8080/articles -d '{"title":"new","year":2025}'
# {"inserted":1}
curl -X POST 'http://127.0.0.1:8080/articles?returning=id' -d '{"title":"newer","year":2026}'
# [{"id":3}]Why the HTTP endpoint is not a separate binary
For clients that want no driver at all: fetch,
curl, or any language's standard library. The schema is already in
the database, so the surface is derived from it.
Not a separate binary. The HTTP endpoint is a second listener in
the same process as fenec-server. fenecdb has a single writer, and
two processes writing one file corrupt it. Being in the same process also
keeps the sync policy, the checkpoint, the memory ceiling and the shutdown
signal in one place — --sync always covers HTTP writes too.
Raw FenecQL
When the REST routes do not fit, send FenecQL to /query.
Parameters stay parameters.
curl -X POST http://127.0.0.1:8080/query \
-H 'content-type: application/json' \
-d '{"query":"get articles where year >= $1 limit $2","params":[2024, 10]}'- A text is parsed once.
/querykeeps what it parsed by the text, so the values go as parameters — a page'slimitandoffsettoo (Pages by parameters): written into the text, every page was a statement of its own. - The answer is the rows, a JSON array — or, when the query asks
for facets,
{"rows": [...], "facets": {"brand": [{"value": "acme", "count": 12}, ...]}}. A write answers{"affected": n}, and one withreturningthe rows it wrote, as a read does, with itsFenec-Seq: a job queue's claim (Job queues). - From Python:
pip install fenecdbgives a client with no dependencies.
How facets and counts travel in an answer
The counts answer for the whole set the query matched, not for a row, so
they go beside the rows rather than into one, and a query that asks for none
keeps the bare array every client reads. The same holds for a line of a
/batch ({"rows": [...], "facets": {...}}), for
GET /<name>?facet=..., whose list is FenecQL's
(facet=brand top 5,color), and beside a count
({"count": 3, "facets": {...}}). A subscription keeps a shape's rows and carries no
facets; a live query over the server (live on
connect(), useLiveQuery) runs its query again
after each write, facets and all. A highlight() is a column
like any other, under its label.
From Python, pip install fenecdb — the standard library
alone — makes the same calls, blocking or in an event loop:
from fenecdb import AsyncClient, Client
db = Client("http://127.0.0.1:8080", token="...")
db.query("get articles where year >= $1 limit 10", [2024])
async with AsyncClient("http://127.0.0.1:8080", token="...") as db:
await db.query("get articles where year >= $1 limit 10", [2024])AsyncClient keeps one connection alive and opens it again
when the server has closed it; calls made at once over one client go one
after another over it, so requests side by side take a client each.
Client.follow reads the change stream
as a consumer, and python -m fenecdb.relay hands it on to
another program or a webhook: see Services
and events.
Batches
POST /batch runs several statements, one per line, as a single
all-or-nothing write. If one fails, the ones before it are undone and the
answer says so.
{"error":"collection `tasks` has no field `nofield`","completed":0,"at":1}A statement that matches no row is no error, and the batch goes on. To
make a count a condition, end the write with require N: unless
it wrote exactly N rows the batch stops with
412 and is put back, a transfer's debit and its credit together
(Transfers).
{"error":"unmet: `set accounts` wrote 0 rows, and requires 1","completed":0,"at":1}Every client sends one: db.batch([...], { idempotencyKey }) in
JavaScript, batch(...) in Python, Batch in Go,
BatchAsync in .NET, and the remote clients of Swift, Kotlin and
Dart; a batch that stops throws the client's error with the statement's
at and the status.
How a batch runs, fails and is split into lines
POST /batch takes one query body per line as NDJSON. The
statements run in order under a single write lock, as one block: their writes
land in the file as one record, or none of them do. The first error puts back
what the statements before it did, and the answer says so: at
is the statement that stopped it, counted from 0, and completed
how many stay applied — none of a block.
A batch whose every statement only reads — a get, with or
without require — is a snapshot instead: every read at the one
change Fenec-Seq names, no write landing between the first and
the last. When it is long — an aggregate, a scan, a filtered count over
10 000 rows or more — the server pins what it reads under the read lock and
reads it with no lock held, so writes land beside it; otherwise, and where
an index answers a statement, it runs under one read lock, and writes wait
for it as they wait for any read. A reconciliation over a million journal
entries took 140 to 200 ms, and a transfer arriving meanwhile waited up to
187–512 ms; pinned, 5.4–29 ms (make recon-bench). A long
POST /query read is pinned the same way.
No other writer slips in between, a read in the batch sees the writes
before it, and a subscriber is woken once, for all of them. A
create, a drop or a create index in it
is put back with the rest. A compact rewrites the file, which
cannot be put back, so a batch holding one runs each statement on its own:
it stops at the first error, and
completed says how many were applied. Either way a failed batch
says what it left behind, because a silent error would
permanently separate a client's optimistic local state from the
server's.
The body is NDJSON rather than a JSON array because fenecdb's JSON parser
deliberately does not accept nested objects, so
{"statements":[{...}]} could not be parsed anyway. Instead of
writing a second parser, the boundary was put at the end of a line — by the
escaping rules no JSON encoder can write a bare newline into the body.
Retrying a write
Send an Idempotency-Key header with a write, and retrying it
after a timeout cannot write it twice: a repeat gets the first answer back.
curl -X POST http://127.0.0.1:8080/query \
-H 'Idempotency-Key: 5f0c2d6e-order-1142' \
-d '{"query":"put orders {customer: $1, total: $2}","params":[7, 129.5]}'- One key per write. The same key with a different request is refused (422).
- A failed write keeps no key, so a retry runs it again.
How an Idempotency-Key replays, and what it costs
A put with no id makes a row each time it runs, so a client
that sends a write again after a timeout or a dropped connection — not
knowing whether the first landed — may write it twice. Send an
Idempotency-Key with it, one per write, and a request sent again
with the key is answered as the first was, with
Idempotent-Replayed: true, and not made again. Its
Fenec-Seq is where the database stands as it replays, which
holds the write, so a read sent to a replica with Fenec-After
waits for it as after the first answer.
- The key and the answer are kept in the same block as the write, under the write lock: they land together, so a crash cannot leave a write whose retry makes it again, and a second request with the key waits for the first and is handed its answer.
- A write that failed keeps no key: sent again, it runs again.
- A key is for one request: the same key with another method, path or body is refused (422).
- Under a scoped token the keys are its subject's (
sub), so no user is handed another's answer; a token with no subject, or asubthat is not a text or is"", is refused a key (400). - REST writes,
/queryand/batchtake one, acompact— which cannot be put back — does not; a read ignores it. An answer over 64 KB is kept as its status and a note. - Keys are kept for
--idempotency-ttlseconds, a day unless given, in a_idempotencycollection the change stream leaves out. Itsatis@ttl: a key past its time is gone at once, and the sweeper deletes it.
A write without a key costs what it did; with one, 16 200 single puts a second over HTTP against 18 300 without.
Where next
- HTTP reference
Every route, the filters of the query string, vector search and lookups over REST, the headers that hold a write or mark a read, every status, subscriptions, polling and the change stream.
- HTTP security and scoped access
A bearer token, CORS and a read-only endpoint; JSON Web Tokens held to a policy file, row by row; the keys that verify them.
- Your language
The same calls in Python, JavaScript, Go, Rust, .NET, Java, PHP and Ruby, and the apps that keep the database on the device.
- Server
Running
fenec-server: flags, durability, limits, containers.