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:8080

Then 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.

shell
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`"} 404
install
pip install fenecdb   # the standard library alone
search.py
from 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`
search.ts
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']);
install
go get github.com/fenecdb/fenecdb/integrations/go   # the standard library alone
main.go
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)

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`
}
install
cargo add ureq@2 --features json
cargo add serde_json
main.rs
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]))?;
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 => { /* ... */ }
}
install
dotnet add package FenecDb   # HttpClient and System.Text.Json alone
Program.cs
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<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);
Search.java
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();
search.php
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']);
search.rb
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. /query keeps what it parsed by the text, so the values go as parameters — a page's limit and offset too (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 with returning the rows it wrote, as a read does, with its Fenec-Seq: a job queue's claim (Job queues).
  • From Python: pip install fenecdb gives 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 a sub that is not a text or is "", is refused a key (400).
  • REST writes, /query and /batch take one, a compact — 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-ttl seconds, a day unless given, in a _idempotency collection the change stream leaves out. Its at is @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.