# fenecdb > Minimal embedded document database: one file, live queries an app's > screens follow, a replica on the device that syncs with a server, a server > over HTTP with a file per tenant, and search built in -- BM25 over text, > with typos, highlights and facets, and HNSW vectors for meaning. It runs in > the browser as WebAssembly, in an app, and as a server. Its query language > is FenecQL, which is not SQL but reads SQL's everyday spellings. Writing FenecQL -- the reference below spells it out in full: - It is not SQL, but it reads SQL's everyday spellings as the statements they mean: `CREATE TABLE` and `CREATE INDEX`, `INSERT INTO t (a, b) VALUES (1, 'x')`, `UPDATE t SET a = 1 WHERE ...`, `IN (1, 2)`, `BETWEEN`, `LIKE` and `ILIKE`, `ON CONFLICT DO NOTHING` and `DO UPDATE SET`, `DISTINCT`, `EXISTS`, single quotes and `||` (https://fenecdb.com/docs/sql). A double-quoted word is text, never a column: a field is written bare, `where name = 'Ada'`. Every row has an `id` (an int the database makes; no `rowid`, no `_id`). A JOIN on a key is read as `lookup`, which FenecQL writes `get orders lookup users on id = user_id` -- each order with its user nested; the clauses after `lookup` are the looked-up collection's -- and a filter by another collection's rows is an uncorrelated `in (get select where ...)`, or `not in (...)` for the rows it does not name. There is no interactive transaction: a `/batch` of statements (in the page, a `run` of several) lands whole or not at all, and `require ` after a write makes its count a condition that puts the whole batch back. - The aggregates -- `count(*)`, `count(f)` (the rows where `f` is not null), `count(distinct f)`, `sum`, `avg`, `min`, `max`, `first(f by k)`, `last(f by k)` -- go in the select list, over every match or per `group `, and `having` keeps groups. Distinct values are a group: `select city group city`. - Text: `like 'a%'` and `ilike` take SQL's `%` and `_`; `~` is a case-insensitive *contains* (`email ~ "@example.org"`), never a regular expression, and `match` ranks words over an `@text` field. - Square brackets are list literals and nothing else: `tags: ["a", "b"]`, `tags [text]`. Nothing here marks an optional part with them; a clause you do not need is simply left out. - Strings take double or single quotes. Parameters are `$1`, `$2`, ...; a vector parameter is a list of numbers. A timestamp is written as text, `"2026-09-01T10:00:00Z"`, or `"2026-01-01"` in a comparison. - Types: `bool int float text bytes timestamp`, lists such as `[text]`, `json` (any JSON value; a path such as `meta.source` reads into it), `geo` (a point, `[lon, lat]`), `vector`, `vector` and `sparse`. A typed field holds no object; a `json` field does. There is no decimal -- money is an `int` of cents -- and no UUID type (use `text @hash`). - Indexes: `@hash` for equality, `@unique` for a value only one row may hold, `@sorted` for ranges and for `order` with a `limit`, `@ttl(30m)` for rows that expire, `@text` for `match`, `@hnsw(cosine)` (or `l2`, `dot`) for `near`. Only an `and` chain uses an index: `=` and `in [...]` on a `@hash` field or on `id`, `<`, `<=`, `>`, `>=` and `=` on a `@sorted` field; everything else scans. `explain get ...` runs a query and returns the path it took. - Text is ordered by its bytes; `order title collate tr` orders one query's text as Turkish does (ICU's `tr`: `ç` after `c`, `ı` before `i`). A field declared `name text collate tr` compares by it wherever its text is ordered -- `order`, `<` and `>` in `where`, `min`, `max`, its `@sorted` index -- while `=` compares bytes. - A text field declared `nocase` (`email text nocase @unique`) compares by its case fold everywhere -- `=`, `in`, its indexes, `order`, `group`, `facet` -- and keeps what was written: `Alice@x.com` is `alice@X.COM`. - `match` ranks by BM25 and `near` by distance; each decides the order, so neither combines with `order`, and the two together need `fuse`, which ranks by both. `match ... fuzzy` finds a word typed wrong and `match ... prefix` one half typed. Each returns at most 10 000 rows (`limit + offset`) and adds a `_score` column. - After `lookup`, every clause belongs to the child collection and `limit` counts children per parent. `on ` names the child's field that holds the parent's `id` (`get orders lookup items on order_id`); `on = ` runs the other way (`get items lookup orders on id = order_id` attaches each item's order). `required` goes right after `on ` and keeps only the parents with a matching child -- SQL's `exists`; `count` goes before `lookup`. Every statement, by example: ```fenecql create collection articles (title text @hash, views int, tags [text], published timestamp @sorted, body text @text, embed vector<4> @hnsw(cosine)) put articles {title: "Rust", views: 10, tags: ["lang"], published: "2026-09-01T10:00:00Z", body: "Ownership and borrowing", embed: [0.1, 0.2, 0.3, 0.4]} put articles [{title: "Zig", views: 3}, {title: "Go", views: 7}] get articles select title, views where views < 100 and tags has "lang" order views desc limit 5 offset 5 get articles select title order title collate tr, views desc limit 20 get articles where title in ["Rust", "Go"] count get articles select title, sum(views), max(published) where tags has "lang" group title order sum(views) desc limit 5 get articles order published desc limit 20 lookup comments on article_id where score >= 4 order published desc limit 3 get articles count lookup comments on article_id required where score = 5 get comments where score >= 4 lookup articles on id = article_id select title insert into articles (title, views) values ('Nim', 4), ('Odin', 2) get articles where title like 'R%' and views between 1 and 100 and title not in ["Zig"] get articles select title, highlight(body) match body "borowing" fuzzy facet tags limit 10 get articles select title where published >= "2026-01-01" near embed $1 limit 3 get articles match body "borrowing" near embed $1 fuse limit 10 get articles match body "borrowing" rerank embed $1 candidates 200 limit 10 explain get articles where views < 100 order published desc limit 5 set articles {views: views + 1} where title = "Rust" require 1 del articles where views > 100000 create index on articles (views) @hash drop collection articles ``` From JavaScript, `db.from('articles').where('views', '<', 100).order('published', 'desc') .limit(10).rows()` builds the same FenecQL with every value a parameter, and `db.live(query, draw)` runs it again after every write to what it reads. --- Source: https://fenecdb.com/docs/ # Documentation fenecdb is an embedded document database small enough to run inside a browser tab. It also keeps an app's file on a phone and serves any language over HTTP, a file per tenant; search is built in. **Try it now.** The [Playground](https://fenecdb.com/playground) runs the engine in this tab -- nothing to install. To put it to work, the [Quickstart](https://fenecdb.com/docs/quickstart) starts the server and runs a first query in a few commands. ## What it is Your documents live in one file, indexed as you declare, and you query them with one language, FenecQL — in the page, in an app, from Rust, or over HTTP from a server. A screen's live query runs again when a write changes what it reads, a replica on the device syncs with the server, and the server takes many clients' writes into the one file through one writer, their disk syncs shared, and many tenants into a file each. ```fenecql create collection articles ( title text @text, tags [text], year int @hash, published timestamp @sorted ) get articles select title where year >= 2024 and tags has "rust" order published desc limit 10 get articles select title, highlight(title) match title $1 fuzzy limit 10 ``` One file, and search built in fenecdb keeps its documents in one file and indexes them — hash, unique, ordered, text, points and vectors. The indexes are held in memory and built from the documents, the first time a query reads each, and a vector graph is saved in the file too, so an open need not build it again. It answers queries from inside a browser tab, in an app, or from a server any language reaches over HTTP. Everything in these docs describes the engine as it is built today. Where something costs you a capability, that cost is stated next to the feature rather than at the bottom of the page. One file. Records are appended to immutable 8 MiB segments and the byte sequence on disk is the byte sequence in memory, so a read decodes directly over the arena. A `HashMap` offset index is the only helper structure in the whole store. Search is not bolted on. `@text` is an index and `match` a query clause, ranked by BM25, with `fuzzy`, `highlight()` and `facet` beside it; for search by meaning, `vector` is a type, `@hnsw` an index and `near` a clause. The planner knows them all, so a filter and a search are planned together rather than stacked on each other. ## Where to go next Pick the route that matches what you are doing. The sidebar files pages by what they are instead, so a few sit under another heading there: FenecQL and From SQL are Reference, the place to start writing queries, and Build from source is Contribute. [Every page](#pages) is listed below the routes. ### New here - [Playground](https://fenecdb.com/playground) The real engine in this tab, with collections to browse and a FenecQL editor. Nothing to install. - [Quickstart](https://fenecdb.com/docs/quickstart) Start the server, write two rows, query and search them with curl and from your language, and see them in the studio. - [Quickstart: in the browser](https://fenecdb.com/docs/quickstart-browser) The database in a page, with no server: a Vite project, one package and a script. - [Install](https://fenecdb.com/docs/install) The install script, Homebrew, a .deb or .rpm, a tarball, the image, a package for your language, or a build from source. - [Your language](https://fenecdb.com/docs/languages) Python, JavaScript, Go, Rust, .NET, Java, PHP, Ruby, Swift, Kotlin and Dart: one program in each, tested against a real server. - [From SQL](https://fenecdb.com/docs/sql) The SQL it reads as written, what it refuses and what to write instead, and where the meaning is not SQL's. ### Build an app - [FenecQL](https://fenecdb.com/docs/fenecql) The language: a tour from the first write to a lookup and a total, then a reference page for each family of statements. - [JavaScript and TypeScript](https://fenecdb.com/docs/javascript) The browser client, the query builder and generated TypeScript types. - [Mobile and native apps](https://fenecdb.com/docs/mobile) Swift, Kotlin and Flutter: a file on the device, live queries driving the UI. - [HTTP endpoint](https://fenecdb.com/docs/http) Start a server and send it FenecQL from curl or any language's standard library: raw statements, batches and writes that are safe to retry. The [reference](https://fenecdb.com/docs/http-reference) has every route, status and stream, and [security and scoped access](https://fenecdb.com/docs/http-access) the tokens and policies. - [Sync](https://fenecdb.com/docs/sync) A local replica that reads without the network and writes optimistically. - [Instead of Redis](https://fenecdb.com/docs/redis) Counters, rate limits, locks, sessions, leaderboards and more, each a recipe a test runs. - [Integrations](https://fenecdb.com/docs/integrations) LangChain, LlamaIndex, the Vercel AI SDK and React. - [Agents and MCP](https://fenecdb.com/docs/mcp) fenecdb as an agent's search and memory tool over the Model Context Protocol. - [Import](https://fenecdb.com/docs/import) A collection from SQLite or a live PostgreSQL table in one command. - [Serverless and Cloudflare](https://fenecdb.com/docs/serverless) A database in a Durable Object, or a function talking to a server. ### Run it in production - [Server](https://fenecdb.com/docs/server) Running `fenec-server`: flags, tokens, durability, limits, containers. - [Studio](https://fenecdb.com/docs/studio) The admin pages the server can serve: collections, rows, a query editor and the server's numbers. - [Replication](https://fenecdb.com/docs/replication) Read replicas, promotion, archives and backups restored to a moment, every tenant of a node archived at once. - [Tenants and sharding](https://fenecdb.com/docs/sharding) A file per tenant behind a router: two nodes and a router on one machine, a tenant, and a node lost and failed over. The guides, the reference and the design follow it. - [Monitoring](https://fenecdb.com/docs/monitoring) Metrics, dashboards, a slow-statement log, an audit log and traces. - [Datadog and others](https://fenecdb.com/docs/datadog) Under Datadog, New Relic, Elastic and Grafana Cloud: the metrics check, the logs, a dashboard and traces. ### The details - [Limits](https://fenecdb.com/docs/limits) What it does not do, and every hard-coded ceiling. - [File format](https://fenecdb.com/docs/file-format) The layout of a file: its record kinds, their bodies, and why each exception exists. - [How it works](https://fenecdb.com/docs/concepts) Segments, the mapped file, one writer, and why fenecdb has no buffer pool of its own; the four ways it ships and how the crates fit together. - [Benchmarks](https://fenecdb.com/docs/benchmarks) YCSB against SQLite, PostgreSQL, MongoDB and Redis, writers beside readers, requests, text and vector search, and sizes, each family on a page with its method. - [Compare](https://fenecdb.com/docs/compare) Beside PostgreSQL, SQLite and PGlite: when to choose each. - [Build from source](https://fenecdb.com/docs/build) Clone the repository, build with cargo and make, run the tests. ## Every page The sidebar's pages, grouped as it groups them. Start - [Quickstart](https://fenecdb.com/docs/quickstart) - [Quickstart: in the browser](https://fenecdb.com/docs/quickstart-browser) - [Install](https://fenecdb.com/docs/install) Clients - [Your language](https://fenecdb.com/docs/languages) - [JavaScript and TypeScript](https://fenecdb.com/docs/javascript) - [Mobile and native apps](https://fenecdb.com/docs/mobile) - [HTTP endpoint](https://fenecdb.com/docs/http) - [Embedded Rust](https://fenecdb.com/docs/embedding) - [The shell](https://fenecdb.com/docs/shell) - [Integrations](https://fenecdb.com/docs/integrations) - [Agents and MCP](https://fenecdb.com/docs/mcp) Guides - [Sync](https://fenecdb.com/docs/sync) - [Instead of Redis](https://fenecdb.com/docs/redis) - [Job queues](https://fenecdb.com/docs/queues) - [Points and distances](https://fenecdb.com/docs/geo) - [Analytics and market data](https://fenecdb.com/docs/analytics) - [Services and events](https://fenecdb.com/docs/microservices) - [Serverless and Cloudflare](https://fenecdb.com/docs/serverless) - [Import](https://fenecdb.com/docs/import) Reference - [FenecQL](https://fenecdb.com/docs/fenecql) - [Reading data](https://fenecdb.com/docs/fenecql-reading) - [Writing data](https://fenecdb.com/docs/fenecql-writing) - [Collections and types](https://fenecdb.com/docs/fenecql-schema) - [Indexes](https://fenecdb.com/docs/fenecql-indexes) - [Lookup and subqueries](https://fenecdb.com/docs/fenecql-lookup) - [Aggregates and grouping](https://fenecdb.com/docs/fenecql-aggregates) - [Text search](https://fenecdb.com/docs/fenecql-search) - [Vector search](https://fenecdb.com/docs/fenecql-vector) - [From SQL](https://fenecdb.com/docs/sql) - [HTTP reference](https://fenecdb.com/docs/http-reference) - [HTTP security and scoped access](https://fenecdb.com/docs/http-access) - [Limits](https://fenecdb.com/docs/limits) - [File format](https://fenecdb.com/docs/file-format) Operate - [Server](https://fenecdb.com/docs/server) - [Studio](https://fenecdb.com/docs/studio) - [Replication](https://fenecdb.com/docs/replication) - [Monitoring](https://fenecdb.com/docs/monitoring) - [Datadog and others](https://fenecdb.com/docs/datadog) - [Grafana](https://fenecdb.com/docs/grafana) Tenants and sharding - [Tenants and sharding](https://fenecdb.com/docs/sharding) - [Tenants](https://fenecdb.com/docs/sharding-tenants) - [Replicas and failover](https://fenecdb.com/docs/sharding-failover) - [Regions](https://fenecdb.com/docs/sharding-regions) - [The global entry](https://fenecdb.com/docs/sharding-entry) - [Read replicas](https://fenecdb.com/docs/sharding-read-replicas) - [Sharding reference](https://fenecdb.com/docs/sharding-reference) - [Sharding design](https://fenecdb.com/docs/sharding-design) Design and comparisons - [How it works](https://fenecdb.com/docs/concepts) - [Compare](https://fenecdb.com/docs/compare) - [vs PostgreSQL](https://fenecdb.com/docs/vs-postgres) - [vs SQLite](https://fenecdb.com/docs/vs-sqlite) - [vs PGlite](https://fenecdb.com/docs/vs-pglite) Benchmarks - [Benchmarks](https://fenecdb.com/docs/benchmarks) - [YCSB benchmarks](https://fenecdb.com/docs/benchmarks-ycsb) - [Concurrency and request benchmarks](https://fenecdb.com/docs/benchmarks-requests) - [Text search benchmarks](https://fenecdb.com/docs/benchmarks-text) - [Vector search benchmarks](https://fenecdb.com/docs/benchmarks-vector) - [Size and memory benchmarks](https://fenecdb.com/docs/benchmarks-size) Contribute - [Build from source](https://fenecdb.com/docs/build) Coding agents read the same pages as text: [llms.txt](https://fenecdb.com/llms.txt) is a brief with the rules FenecQL does not share with SQL, and [llms-full.txt](https://fenecdb.com/llms-full.txt) is every page in one file. ## What it gave up Staying small meant leaving some things out on purpose. The main ones: - **One writer.** Many readers, but one write or batch at a time. - **No SQL dialect, and a join only on a key.** Every client speaks FenecQL, over HTTP or in process; it reads SQL's everyday spellings as the statements they mean, a `JOIN` on an indexed key among them ([From SQL](https://fenecdb.com/docs/sql)). - **No decimals.** Store money as cents. What it gave up, one by one The list is short, and it is made of consequences of the design rather than a roadmap. - **One writer, no MVCC.** A write, or a `/batch` of them, holds the write lock for as long as it runs and lands whole or not at all; readers read beside it. There are no interactive transactions held open across requests. - **A join only on a key.** A query reads one collection, plus the children `lookup` attaches to each of its rows — and, chained, the children of those — and filters by another's rows only through an uncorrelated `in (get …)`. SQL's `JOIN` on an indexed key reads as one of those, answered flat; a join of any other condition is none. - **No SQL dialect.** FenecQL is the one language, in the page, in Rust and over HTTP. It reads `CREATE TABLE`, `INSERT … VALUES`, `UPDATE … SET`, `JOIN`, `IN (…)`, `EXISTS`, `BETWEEN`, `LIKE` and `ON CONFLICT` as the statements they mean and refuses the rest saying what to write; there is no wire protocol for SQL drivers, and no ORM speaks it. - **No multi-writer replication,** and no change of a field's type in place: it would rewrite every document under the write lock. - **No decimal type.** `float` is binary floating point and unfit for money; the answer is an `int` in cents. - **Objects only in a `json` field.** A typed field refuses one and a list holds one type; a `json` field holds any value JSON does, and a path reads into it. The full accounting, including every hard-coded ceiling, is in [Limits](https://fenecdb.com/docs/limits). How it ships — the browser module, the shell, the server and the Rust library, with what each weighs — and how the crates fit together are on How it works. --- Source: https://fenecdb.com/docs/quickstart # Quickstart Start the server, write two rows, query and search them with curl and from your language, and look at them in the studio. About five minutes. For a database in a web page with no server, see Quickstart: in the browser. - Install `fenec-server` and start it on a file. It runs in the foreground, so the next steps go in another terminal. ```bash curl -fsSL https://fenecdb.com/install.sh | sh ``` The script puts `fenec-server` and the shell in `~/.fenecdb/bin` and edits no shell file: it prints the `export` line that puts that directory on your `PATH`, and the next block runs it. Add the line to `~/.zshrc` or `~/.bashrc` to keep it. What the install script prints ```text Installed fenecdb 0.1.12 (aarch64-apple-darwin) in /Users/you/.fenecdb/bin: fenec the shell: fenec scratch.fenec fenec-server the server: fenec-server --file notes.fenec (fenec-server 0.1.12) /Users/you/.fenecdb/bin is not on your PATH. Add it for this shell: export PATH="$HOME/.fenecdb/bin:$PATH" and put the same line in your shell's startup file (~/.zshrc, ~/.bashrc or ~/.profile) to keep it. For fish: fish_add_path "$HOME/.fenecdb/bin". A first query: https://fenecdb.com/docs/quickstart ``` ```bash export PATH="$HOME/.fenecdb/bin:$PATH" fenec-server --file notes.fenec --studio ``` ```text opened: notes.fenec fenec-server 0.1.12: notes.fenec [sync=250 ms] fenec-http 0.1.12 listening on: http://127.0.0.1:8080 [no auth] ``` ```bash docker run --rm --name quickstart -v quickstart:/data -p 127.0.0.1:8080:8080 \ ghcr.io/fenecdb/fenec-server:0.1.12 \ --http 0.0.0.0:8080 --file /data/notes.fenec --studio --insecure ``` ```text opened: /data/notes.fenec fenec-server 0.1.12: /data/notes.fenec [sync=250 ms] fenec-http 0.1.12 listening on: http://0.0.0.0:8080 [no auth] ``` A container listens beyond localhost, which the server allows only with a token or `--insecure`. Here the port is published to this machine alone (`127.0.0.1:8080`), so the flag opens nothing wider; put a token on the server before it is published any further (Security). The file is in the `quickstart` volume. The server listens on `127.0.0.1:8080`, which only this machine reaches, and asks for no token (`[no auth]`). `--studio` serves the admin page you will open in step 5. If port 8080 is taken, start it with `--http 127.0.0.1:8081` (Docker: `-p 127.0.0.1:8081:8080`) and use that port below. On Windows, use Docker, or WSL with the macOS and Linux steps. Other ways to install: Homebrew, a `.deb` or `.rpm`, a tarball, from source, in [Install](https://fenecdb.com/docs/install). - Make a collection and write two rows. The language is FenecQL: a collection has typed fields, an index on each one you filter, order or search by (`@text` for words, `@sorted` for ranges and order, `@hash` for equality), and the values go as `$1`, `$2` parameters, never into the text. ```bash curl -s http://127.0.0.1:8080/query \ -d '{"query": "create collection notes (title text @text, year int @sorted, kind text @hash)"}' curl -s http://127.0.0.1:8080/query \ -d '{"query": "put notes {title: $1, year: $2, kind: $3}", "params": ["Dunes", 2021, "book"]}' curl -s http://127.0.0.1:8080/query \ -d '{"query": "put notes {title: $1, year: $2, kind: $3}", "params": ["Night at the oasis", 2024, "film"]}' ``` ```text {"message":"collection `notes` created"} {"affected":1} {"affected":1} ``` curl prints each answer with no line break after it, so in a terminal they run together; they are shown one to a line here. - Read the rows back by a filter and an order, by a kind, then search their words, with a typo in the search. ```bash curl -s http://127.0.0.1:8080/query \ -d '{"query": "get notes where year >= $1 order year desc", "params": [2020]}' curl -s http://127.0.0.1:8080/query \ -d '{"query": "get notes where kind = $1", "params": ["book"]}' curl -s http://127.0.0.1:8080/query \ -d '{"query": "get notes select title match title $1 fuzzy", "params": ["oasys"]}' ``` ```text [{"id":2,"title":"Night at the oasis","year":2024,"kind":"film"},{"id":1,"title":"Dunes","year":2021,"kind":"book"}] [{"id":1,"title":"Dunes","year":2021,"kind":"book"}] [{"title":"Night at the oasis","_score":0.62239116}] ``` `kind` is a `@hash` index, which answers equality and gives the studio something to count by. `match` searches the words of a `@text` field, `fuzzy` forgives a misspelling, and `_score` says how well the row matched. - Do the same from your language, with its client or the HTTP client it already has. Each program writes a third row, reads the rows from 2020 on, searches a word spelled wrong and ends on a refused read, with its status and the server's message. Run it once: it writes the row every time. ```bash pip install fenecdb # the standard library alone ``` ```python from fenecdb import Client, FenecError db = Client("http://127.0.0.1:8080") db.query("put notes {title: $1, year: $2, kind: $3}", ["Sand and stars", 2023, "book"]) rows = db.query("get notes where year >= $1 order year desc", [2020]) print("from 2020:", ", ".join(r["title"] for r in rows)) hits = db.query("get notes select title match title $1 fuzzy", ["oasys"]) print("search:", ", ".join(h["title"] for h in hits)) try: db.query("get nowhere") except FenecError as e: print("refused:", e.status, e) ``` ```bash python3 notes.py ``` ```js async function query(q, params = []) { const res = await fetch('http://127.0.0.1:8080/query', { method: 'POST', headers: { 'content-type': 'application/json' }, 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('put notes {title: $1, year: $2, kind: $3}', ['Sand and stars', 2023, 'book']); const rows = await query('get notes where year >= $1 order year desc', [2020]); console.log('from 2020:', rows.map((r) => r.title).join(', ')); const hits = await query('get notes select title match title $1 fuzzy', ['oasys']); console.log('search:', hits.map((h) => h.title).join(', ')); try { await query('get nowhere'); } catch (e) { console.log(`refused: ${e.status} ${e.message}`); } ``` ```bash node notes.mjs # Node 18 or newer: fetch is built in ``` ```bash go mod init notes go get github.com/fenecdb/fenecdb/integrations/go # the standard library alone ``` ```go package main import ( "context" "errors" "fmt" "log" "strings" fenecdb "github.com/fenecdb/fenecdb/integrations/go" ) type Note struct { Title string `json:"title"` Year int `json:"year"` } func titles(notes []Note) string { var out []string for _, n := range notes { out = append(out, n.Title) } return strings.Join(out, ", ") } func main() { ctx := context.Background() db := fenecdb.New("http://127.0.0.1:8080") if _, err := db.Exec(ctx, "put notes {title: $1, year: $2, kind: $3}", "Sand and stars", 2023, "book"); err != nil { log.Fatal(err) } rows, err := fenecdb.QueryAs[Note](ctx, db, "get notes where year >= $1 order year desc", 2020) if err != nil { log.Fatal(err) } fmt.Println("from 2020:", titles(rows)) hits, err := fenecdb.QueryAs[Note](ctx, db, "get notes select title match title $1 fuzzy", "oasys") if err != nil { log.Fatal(err) } fmt.Println("search:", titles(hits)) var refused *fenecdb.Error if _, err := db.Query(ctx, "get nowhere"); errors.As(err, &refused) { fmt.Println("refused:", refused.Status, refused.Message) } } ``` ```bash go run . ``` ```bash cargo new notes && cd notes cargo add ureq@2 --features json cargo add serde_json ``` ```rust use serde_json::{json, Value}; fn query(q: &str, params: Value) -> Result { Ok(ureq::post("http://127.0.0.1:8080/query") .send_json(json!({"query": q, "params": params}))? .into_json()?) } fn titles(rows: &Value) -> String { let titles: Vec<&str> = rows.as_array().unwrap().iter().filter_map(|r| r["title"].as_str()).collect(); titles.join(", ") } fn main() -> Result<(), ureq::Error> { query("put notes {title: $1, year: $2, kind: $3}", json!(["Sand and stars", 2023, "book"]))?; let rows = query("get notes where year >= $1 order year desc", json!([2020]))?; println!("from 2020: {}", titles(&rows)); let hits = query("get notes select title match title $1 fuzzy", json!(["oasys"]))?; println!("search: {}", titles(&hits)); // A refusal is `Error::Status` with the server's status, its body {"error": ...}. match query("get nowhere", json!([])) { Err(ureq::Error::Status(status, res)) => { let body: Value = res.into_json()?; println!("refused: {status} {}", body["error"].as_str().unwrap_or_default()); } other => { other?; } } Ok(()) } ``` ```bash cargo run ``` ```bash dotnet new console -o notes && cd notes dotnet add package FenecDb # HttpClient and System.Text.Json alone ``` ```csharp using FenecDb; using var db = new FenecClient("http://127.0.0.1:8080"); await db.ExecAsync("put notes {title: $1, year: $2, kind: $3}", ["Sand and stars", 2023, "book"]); var rows = await db.QueryAsync("get notes where year >= $1 order year desc", [2020]); Console.WriteLine("from 2020: " + string.Join(", ", rows.Select(r => r.Title))); var hits = await db.QueryAsync("get notes select title match title $1 fuzzy", ["oasys"]); Console.WriteLine("search: " + string.Join(", ", hits.Select(h => h.Title))); try { await db.QueryAsync("get nowhere"); } catch (FenecException e) { Console.WriteLine($"refused: {e.Status} {e.Message}"); } record Note(string Title, int Year); ``` ```bash dotnet run ``` ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.ArrayList; import java.util.regex.Pattern; public class Notes { static final HttpClient HTTP = HttpClient.newHttpClient(); // One FenecQL statement, as the JSON {"query": ..., "params": [...]}. static HttpResponse query(String json) throws Exception { var req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:8080/query")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(json)) .build(); return HTTP.send(req, HttpResponse.BodyHandlers.ofString()); } // The standard library has no JSON: every "title" of an answer, or its "error". static String find(String key, String body) { var found = new ArrayList(); var m = Pattern.compile("\"" + key + "\":\"([^\"]*)\"").matcher(body); while (m.find()) found.add(m.group(1)); return String.join(", ", found); } public static void main(String[] args) throws Exception { query(""" {"query": "put notes {title: $1, year: $2, kind: $3}", "params": ["Sand and stars", 2023, "book"]}"""); var rows = query(""" {"query": "get notes where year >= $1 order year desc", "params": [2020]}"""); System.out.println("from 2020: " + find("title", rows.body())); var hits = query(""" {"query": "get notes select title match title $1 fuzzy", "params": ["oasys"]}"""); System.out.println("search: " + find("title", hits.body())); var refused = query(""" {"query": "get nowhere"}"""); System.out.println("refused: " + refused.statusCode() + " " + find("error", refused.body())); } } ``` ```bash java Notes.java # Java 15 or newer, for the text blocks ``` ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_POSTFIELDS => json_encode(['query' => $q, 'params' => $params]), ]); $body = json_decode(curl_exec($ch), true); return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $body]; } query('put notes {title: $1, year: $2, kind: $3}', ['Sand and stars', 2023, 'book']); [, $rows] = query('get notes where year >= $1 order year desc', [2020]); echo 'from 2020: ', implode(', ', array_column($rows, 'title')), "\n"; [, $hits] = query('get notes select title match title $1 fuzzy', ['oasys']); echo 'search: ', implode(', ', array_column($hits, 'title')), "\n"; [$status, $error] = query('get nowhere'); echo 'refused: ', $status, ' ', $error['error'], "\n"; ``` ```bash php notes.php # with the curl extension ``` ```ruby require "json" require "net/http" # One FenecQL statement; the answer is its status and the decoded JSON body. def query(q, params = []) res = Net::HTTP.post(URI("http://127.0.0.1:8080/query"), JSON.generate(query: q, params: params), "Content-Type" => "application/json") [res.code.to_i, JSON.parse(res.body)] end query("put notes {title: $1, year: $2, kind: $3}", ["Sand and stars", 2023, "book"]) _, rows = query("get notes where year >= $1 order year desc", [2020]) puts "from 2020: #{rows.map { |r| r["title"] }.join(", ")}" _, hits = query("get notes select title match title $1 fuzzy", ["oasys"]) puts "search: #{hits.map { |h| h["title"] }.join(", ")}" status, error = query("get nowhere") puts "refused: #{status} #{error["error"]}" ``` ```bash ruby notes.rb ``` Every program prints the same three lines. ```text from 2020: Night at the oasis, Sand and stars, Dunes search: Night at the oasis refused: 404 collection `nowhere` ``` Java, PHP and Ruby need no package, and Swift, Kotlin and Dart keep the database in the app instead: [Your language](https://fenecdb.com/docs/languages) has each one in full, with its query builder, batches and errors. - Open `http://127.0.0.1:8080/_studio/` in a browser, leave the token empty and press Connect, and pick `notes` in the sidebar. Your rows are in the grid, with the counts by `kind` above them. Enter writes a cell and New row adds one. The notes after step 4, in [fenec studio](https://fenecdb.com/docs/studio). ## Next - [Quickstart: in the browser](https://fenecdb.com/docs/quickstart-browser) The same engine in a web page, with no server: a Vite project, one package and a script. - [The shell](https://fenecdb.com/docs/shell) Run FenecQL on a file from a prompt, to learn the language or to look inside a file. - [FenecQL](https://fenecdb.com/docs/fenecql) The language, from a first write to a lookup and a total. - [Sync](https://fenecdb.com/docs/sync) A replica in the page or the app that reads without the network and writes on its own. - [Install](https://fenecdb.com/docs/install) Homebrew, a package, a tarball, the image, a package for your language, or a build from source. - [Server](https://fenecdb.com/docs/server) Tokens, durability, limits and containers, for running it for real. --- Source: https://fenecdb.com/docs/quickstart-browser # Quickstart: in the browser A database in the page. `@fenecdb/web` is the engine as WebAssembly and a dependency-free client, so a script opens a database, writes rows and queries them with no server. A few lines and a Vite project, and about five minutes. You need Node and npm. Nothing here leaves your browser, and the Playground is the same engine in this tab if you would rather type than install. - Make a project and install the package. ```bash npm create vite@latest my-app -- --template vanilla --no-immediate cd my-app npm install @fenecdb/web ``` With `--template` and `--no-immediate` Vite asks nothing and starts nothing: it makes the folder and stops, and the install brings Vite along with the package. - Replace `src/main.js` with this. It opens a database, makes a collection, writes two rows and shows what two queries return: the rows from 2020 on, newest first, and a search for a word spelled wrong. ```js import { Fenec } from '@fenecdb/web'; import wasm from '@fenecdb/web/fenec.wasm?url'; const db = await Fenec.open(wasm); db.run('create collection notes (title text @text, year int @sorted)'); db.run('put notes {title: $1, year: $2}', ['Dunes', 2021]); db.run('put notes {title: $1, year: $2}', ['Night at the oasis', 2024]); const { rows } = db.run('get notes where year >= $1 order year desc', [2020]); const hits = db.rows('get notes select title match title $1 fuzzy', ['oasys']); const out = document.createElement('pre'); out.textContent = JSON.stringify(rows) + '\n' + JSON.stringify(hits); document.body.append(out); ``` - Start the dev server. ```bash npm run dev ``` ```text > my-app@0.0.0 dev > vite VITE v8.3.5 ready in 1161 ms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose ``` - Open `http://localhost:5173`. The page shows both answers, as JSON. ```text [{"id":2,"title":"Night at the oasis","year":2024},{"id":1,"title":"Dunes","year":2021}] [{"title":"Night at the oasis","_score":0.62239116}] ``` - **The module is a URL.** `Fenec.open` takes the address of `fenec.wasm`, which Vite gives for a file imported with `?url`; `npm run build` copies it into `dist/assets` and the page asks for it there. No Vite configuration is needed. - **`db.run` is synchronous:** it returns the rows and not a promise, since a statement is one call into the module. `db.rows` is `run` with the rows alone. - **The query builder** writes the same statements, each value a parameter: `await db.from('notes').where('year', '>=', 2020).order('year', 'desc').rows()` returns the first answer's rows ([The query builder](https://fenecdb.com/docs/javascript#the-query-builder)). ## Keep it between visits The database lives in the tab's memory: a reload starts it empty, and the script above makes the collection again. `persist` stores it in IndexedDB, the image once and then only the writes since, and `restore` reads it back. This version writes the rows on the first visit alone, and says which visit it is. ```js import { Fenec, persist, restore } from '@fenecdb/web'; import wasm from '@fenecdb/web/fenec.wasm?url'; const db = await Fenec.open(wasm); const kept = await restore(db); // what an earlier visit stored, if one did if (!kept) { db.run('create collection notes (title text @text, year int @sorted)'); db.run('put notes {title: $1, year: $2}', ['Dunes', 2021]); db.run('put notes {title: $1, year: $2}', ['Night at the oasis', 2024]); await persist(db); // into IndexedDB } const { rows } = db.run('get notes where year >= $1 order year desc', [2020]); const hits = db.rows('get notes select title match title $1 fuzzy', ['oasys']); const out = document.createElement('pre'); out.textContent = (kept ? 'restored from IndexedDB' : 'first visit') + '\n' + JSON.stringify(rows) + '\n' + JSON.stringify(hits); document.body.append(out); ``` Save it and the page reloads on its own and says `first visit`. Reload it once more, and the rows come from IndexedDB. ```text first visit [{"id":2,"title":"Night at the oasis","year":2024},{"id":1,"title":"Dunes","year":2021}] [{"title":"Night at the oasis","_score":0.62239116}] ``` ```text restored from IndexedDB [{"id":2,"title":"Night at the oasis","year":2024},{"id":1,"title":"Dunes","year":2021}] [{"title":"Night at the oasis","_score":0.62239116}] ``` A page that writes later calls `persist(db)` after the write. To start again, clear the site's data in your browser's developer tools. No bundler, no npm, and Node Another bundler does what Vite does: import `@fenecdb/web` and hand `Fenec.open` the module's URL, however that bundler spells a file's address. A page whose queries run on a server imports `@fenecdb/web/client` and loads no module at all (Which package). With no bundler, any static file server shows the same page: serve `node_modules` as it is and hand `Fenec.open` the module's path. Save this as `index.html` beside `node_modules`, run `python3 -m http.server 8000` (or `npx serve`) and open `http://localhost:8000`; it shows the first visit's two lines. ```html
loading…
``` **It does not open over `file://`.** A page opened from disk stays on `loading…`: the browser refuses a module `import` from a page with no HTTP origin. Any static server works: `python3 -m http.server`, `npx serve`, nginx. No npm: the same files, the module and the `collate/` data beside it, are `fenec-web-v0.1.12.tar.gz` on the release. In Node, where `fetch` cannot resolve a relative path, hand `Fenec.open` the bytes: ```js import { readFile } from 'node:fs/promises'; import { Fenec } from '@fenecdb/web'; const wasm = await readFile(new URL(import.meta.resolve('@fenecdb/web/fenec.wasm'))); const db = await Fenec.open(wasm); db.run('create collection notes (title text @text, year int @sorted)'); db.run('put notes {title: $1, year: $2}', ['Dunes', 2021]); db.run('put notes {title: $1, year: $2}', ['Night at the oasis', 2024]); console.log(db.run('get notes where year >= $1 order year desc', [2020]).rows); ``` ```text [ { id: 2, title: 'Night at the oasis', year: 2024 }, { id: 1, title: 'Dunes', year: 2021 } ] ``` ## Next - [JavaScript and TypeScript](https://fenecdb.com/docs/javascript) The query builder, live queries as an app's state, a database kept in a file of the page, a schema declared in code, and which package a page needs. - [Sync](https://fenecdb.com/docs/sync) A replica in the page that reads without the network and writes on its own, with a server keeping every page's writes. - [Quickstart: the server](https://fenecdb.com/docs/quickstart) The same collection on `fenec-server`, reached over HTTP from any language, with a page that runs no module at all. - [FenecQL](https://fenecdb.com/docs/fenecql) The language: a tour from the first write to a lookup and a total. --- Source: https://fenecdb.com/docs/install # Install fenecdb is two programs, `fenec-server` and the shell, `fenec`, and a package for each language that reaches the server. Pick the way that suits your machine. The commands here install release **0.1.12**, so what they print is what that release prints. The rest of these docs describe the repository's `main`, which can be ahead of the release: a flag or a statement they show that your install lacks is in the next one, and `fenec-server --help` lists what yours has. | Way | Gives you | On | | [The install script](#install-script) | `fenec-server` and `fenec`, in your home directory, no `sudo` | macOS and Linux, arm64 and x86_64 | | [Homebrew](#homebrew) | the same two, kept up to date by `brew` | macOS and Linux, arm64 and x86_64 | | [.deb and .rpm](#debian-and-fedora) | the same two, in `/usr/bin` | Debian, Ubuntu, Fedora, RHEL; amd64 and arm64 | | [A tarball](#tarball) | the same two, in a folder | macOS and Linux, arm64 and x86_64 | | [The Docker image](#docker) | `fenec-server` alone, under 3 MB | anywhere Docker runs, amd64 and arm64 | | [A package for your language](#packages) | a client for the server, or the database in your app | by language | | [From source](#source) | whatever `main` is today | where Rust builds | There is no Windows build of the programs yet: run the image, or the script inside WSL. The clients run anywhere their language does. ## The install script ```bash curl -fsSL https://fenecdb.com/install.sh | sh ``` It puts `fenec-server` and `fenec` in `~/.fenecdb/bin` (`FENEC_INSTALL` names another directory) with no `sudo`, and edits no shell file: it prints the line that puts the directory on your `PATH` and leaves that to you. ```bash export PATH="$HOME/.fenecdb/bin:$PATH" # in ~/.zshrc, ~/.bashrc or ~/.profile to keep it ``` - **It downloads the latest release** from GitHub over https, with `curl` or a `wget` that checks certificates; `FENEC_VERSION=0.1.12` names a release. - **It checks the tarball** against the release's `SHA256SUMS` before it unpacks anything. That sum comes from the same release as the file, so it catches a damaged or cut-short download, not a release someone replaced. - **Nothing runs until its last line.** The script is functions, and `main "$@"` on its last line is what starts them, so a download cut short runs nothing. To read it before it runs: `curl -fsSL https://fenecdb.com/install.sh | less` ([install.sh](https://fenecdb.com/install.sh)). - **To remove it,** delete `~/.fenecdb`. ## Homebrew ```bash brew install fenecdb/tap/fenecdb ``` The formula installs `fenec-server` and `fenec`, the shell, on macOS and Linux (arm64 and x86_64): the release's own binaries, checked against their SHA-256, not built on your machine. `brew upgrade fenecdb` takes the next release and `brew uninstall fenecdb` removes it. ## .deb and .rpm Each release carries a package for amd64 and one for arm64, with its checksum in `SHA256SUMS`; releases from 0.1.12 on have them. ```bash curl -fLO https://github.com/fenecdb/fenecdb/releases/download/v0.1.12/fenecdb_0.1.12-1_amd64.deb # arm64 on an ARM machine sudo apt install ./fenecdb_0.1.12-1_amd64.deb ``` ```bash curl -fLO https://github.com/fenecdb/fenecdb/releases/download/v0.1.12/fenecdb-0.1.12-1.x86_64.rpm # aarch64 on an ARM machine sudo dnf install ./fenecdb-0.1.12-1.x86_64.rpm ``` They install `fenec` and `fenec-server` in `/usr/bin` and depend on nothing: the binaries are static. ## A release tarball ```bash TARGET=aarch64-apple-darwin # or x86_64-apple-darwin, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl curl -LO https://github.com/fenecdb/fenecdb/releases/download/v0.1.12/fenec-v0.1.12-$TARGET.tar.gz tar -xzf fenec-v0.1.12-$TARGET.tar.gz cd fenec-v0.1.12-$TARGET ./fenec-server --help ``` The folder holds `fenec-server` and `fenec`, and nothing needs installing: run them from there, or copy them where your `PATH` looks. The release page lists every file. ## The Docker image The image holds `fenec-server` and nothing else: no shell, no libc, under 3 MB. Its data lives in `/data`, which the command below keeps in a volume. ```bash docker run -d --name fenecdb -v fenecdata:/data \ -p 127.0.0.1:8080:8080 -e FENEC_HTTP_TOKEN=a-token \ ghcr.io/fenecdb/fenec-server:0.1.12 ``` A container listens beyond localhost, so the server asks for a token, or for `--insecure` where you publish the port to your own machine only, as the [Quickstart](https://fenecdb.com/docs/quickstart) does. Its command is `--http 0.0.0.0:8080 --file /data/data.fenec --sync 250`: write every flag out when you override it, or the file is dropped. Run it in a container has the rest: one instance a file, memory limits, Kubernetes. ## A package for your language A server is reached from any language with its own HTTP client; these packages are the ones that do the typing for you, each its standard library alone, and the ones that keep the database in your app or your page. | Language | Install | Read | | Python | `pip install fenecdb` (PyPI) | [Your language](https://fenecdb.com/docs/languages) | | JavaScript, TypeScript | `npm install @fenecdb/web`: the client, and the database in a page | [Quickstart: in the browser](https://fenecdb.com/docs/quickstart-browser), [JavaScript and TypeScript](https://fenecdb.com/docs/javascript) | | Go | `go get github.com/fenecdb/fenecdb/integrations/go` | [Your language](https://fenecdb.com/docs/languages) | | .NET | `dotnet add package FenecDb` (NuGet) | [Your language](https://fenecdb.com/docs/languages) | | Swift | SwiftPM: `github.com/fenecdb/fenecdb`, product `FenecDB` | [Mobile and native apps](https://fenecdb.com/docs/mobile) | | Kotlin, Java | `com.fenecdb:fenecdb-android` (the AAR) or `com.fenecdb:fenecdb` (the JVM), on Maven Central | [Mobile and native apps](https://fenecdb.com/docs/mobile) | | Dart, Flutter | `flutter pub add fenecdb_flutter`, or `fenecdb` in Dart alone (pub.dev) | [Mobile and native apps](https://fenecdb.com/docs/mobile) | | Rust | `fenec-core` and `fenec-ql` from the repository: the engine as a library, with no dependencies | [Embedded Rust](https://fenecdb.com/docs/embedding) | | Java, PHP, Ruby | nothing: the language's own HTTP client | [Your language](https://fenecdb.com/docs/languages) | React, Cloudflare and LangChain have packages of their own (Integrations). ## From source For a build the releases do not have, or to work on fenecdb: clone the repository and build with `cargo` and `make`. Build from source has the commands, the tests and what each make target does. ## Checking a download A release's files are listed with their checksums in `SHA256SUMS`: ```bash curl -LO https://github.com/fenecdb/fenecdb/releases/download/v0.1.12/SHA256SUMS shasum -a 256 -c --ignore-missing SHA256SUMS # Linux: sha256sum -c --ignore-missing SHA256SUMS ``` ## Next - **Run the server and write a row:** the Quickstart. - **Open a file in the shell:** [The shell](https://fenecdb.com/docs/shell). - **Run it for real:** tokens, durability, limits and containers are on [Server](https://fenecdb.com/docs/server). --- Source: https://fenecdb.com/docs/languages # Your language fenecdb speaks HTTP and JSON, so every language reaches it with the HTTP client it already has. No driver to install. Every example on this page is a program that runs against a real server on each change to fenecdb (`make languages-test`). Python, JavaScript, Go and .NET have official clients, each its standard library alone; in the other languages these few lines are the whole client. Swift, Kotlin and Dart embed the database in the app, a file on the device ([Mobile and native apps](https://fenecdb.com/docs/mobile)), and reach a server with `Fenec.connect`; their tabs run the same flow in their own tests. Each tab ends with a full example: one app, Notes, in every language, a folder of a few files to clone and run ([examples](https://github.com/fenecdb/fenecdb/tree/main/examples)), and beside them a whole shop in Next.js ([shop](https://github.com/fenecdb/fenecdb/tree/main/examples/shop)), a double-entry payments ledger on Node ([ledger](https://github.com/fenecdb/fenecdb/tree/main/examples/ledger)) and courier dispatch across tenants ([dispatch](https://github.com/fenecdb/fenecdb/tree/main/examples/dispatch)). ## Start a server ```bash fenec-server --file app.fenec --http 127.0.0.1:8080 --http-token secret ``` Then pick your language. 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`, and the token as `Authorization: Bearer`. ## Connect and search ```bash pip install fenecdb # the standard library alone ``` ```python 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` ``` Or let the [query builder](#builder) write the statement, every value a parameter, every name checked: the read's text to the byte, and the search as `match title $1 fuzzy 2`, two edits at most, which is what a plain `fuzzy` allows a word anyway, so the rows are the same. ```python docs = db.collection("docs") docs.insert({"title": "Night at the oasis", "year": 2024}) rows = docs.select("title", "year").where("year", ">=", 2020).order("year", "desc").limit(5).rows() hits = docs.select("title").match("title", "oasys", fuzzy=True).rows() n = docs.where("title", "~", "Dunes").count() ``` `batch([(text, params), ...])` runs statements as one block, all or none -- a `FenecError` that stops it says which (`e.at`, from 0); `idempotency_key=` on `query` and `batch`, or `with_idempotency_key(key)` for every write of a copy, makes a write once however often it is sent (`db.replayed`), and `db.seq` is the change the last write left the database at. `AsyncClient` does the same in an event loop, the endpoints of its `collection(...)` awaited; LangChain and LlamaIndex have stores of their own ([Integrations](https://fenecdb.com/docs/integrations)). [Full example](https://github.com/fenecdb/fenecdb/tree/main/examples/python): Notes, a CLI over the server with the builder, `match`, `fuse` and the change stream. ```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']); ``` Or `npm install @fenecdb/web`: its `connect` sends the [query builder](#builder)'s statements to a server, the same code that runs in the page. ```ts import { connect } from '@fenecdb/web'; import type { FenecSchema } from './fenec-schema.js'; // fenec types app.fenec const db = connect('http://127.0.0.1:8080', { token: 'secret' }); 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(); // rows: { title: string | null; year: number | null }[] const hits = await db.from('docs').select('title').match('title', 'oasys', { fuzzy: true }).rows(); // hits: { title: string | null; _score: number }[] ``` The same code runs as plain JavaScript, without the types. In the browser there is no server at all: the same database runs in the page ([JavaScript and TypeScript](https://fenecdb.com/docs/javascript)). [Full example](https://github.com/fenecdb/fenecdb/tree/main/examples/node-server): Notes from Node through `@fenecdb/web/client`, its live queries run on the server; in the page, [web-local](https://github.com/fenecdb/fenecdb/tree/main/examples/web-local) and [react](https://github.com/fenecdb/fenecdb/tree/main/examples/react). ```bash go get github.com/fenecdb/fenecdb/integrations/go # the standard library alone ``` ```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` } ``` Or let the [query builder](#builder) write the statement, immutable, each step a copy: ```go docs := db.From("docs") docs.Insert(ctx, fenecdb.D("title", "Night at the oasis", "year", 2024)) query := docs.Select("title", "year").Where("year", ">=", 2020).Order("year", "desc").Limit(5) rows, err := fenecdb.RowsAs[Doc](ctx, query) hits, err := docs.Select("title").Match("title", "oasys", fenecdb.Fuzzy(2)).Rows(ctx) n, err := docs.Where("title", "~", "Dunes").Count(ctx) ``` `Query` gives rows as `[]map[string]any`; `Batch`, `IdempotencyKey`, `After` for a replica, `Subscribe`, `Changes` and `WithTenant` cover the rest of the [HTTP endpoint](https://fenecdb.com/docs/http) and its [reference](https://fenecdb.com/docs/http-reference) ([integrations/go](https://github.com/fenecdb/fenecdb/tree/main/integrations/go)); a refusal is an `*fenecdb.Error`, and a batch's says which statement stopped it (`At`, from 0). A `[]float32` goes out as the decimals that read back as each `f32`, so a vector round-trips to the bit. Without the SDK, `net/http` and `encoding/json`: ```go // query runs one FenecQL statement and decodes its answer into out. func query(out any, q string, params ...any) error { body, _ := json.Marshal(map[string]any{"query": q, "params": params}) req, _ := http.NewRequest("POST", "http://127.0.0.1:8080/query", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer secret") res, err := http.DefaultClient.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode >= 300 { e := &FenecError{Status: res.StatusCode} json.NewDecoder(res.Body).Decode(e) return e } if out == nil { return nil } return json.NewDecoder(res.Body).Decode(out) } query(nil, "create collection if not exists docs (title text @text, year int @sorted)") query(nil, "put docs {title: $1, year: $2}", "Dunes", 2021) query(nil, "put docs {title: $1, year: $2}", "Night at the oasis", 2024) var rows []struct { Title string `json:"title"` Year int `json:"year"` } err := query(&rows, "get docs select title, year where year >= $1 order year desc limit $2", 2020, 5) ``` `FenecError` is a struct of the status and the body's `error`. [Full example](https://github.com/fenecdb/fenecdb/tree/main/examples/go): Notes, a CLI over the server with the builder, typed rows and a subscription. ```bash cargo add ureq@2 --features json cargo add serde_json ``` ```rust use serde_json::{json, Value}; fn query(q: &str, params: Value) -> Result { 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 => { /* ... */ } } ``` Or embed the engine in your process, with no server ([Embedded Rust](https://fenecdb.com/docs/embedding)). [Full example](https://github.com/fenecdb/fenecdb/tree/main/examples/rust): Notes with `fenec-core` embedded, a file and no server. ```bash dotnet add package FenecDb # HttpClient and System.Text.Json alone ``` ```text 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]); var hits = await db.QueryAsync("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); ``` Or let the [query builder](#builder) write the statement, immutable, each call a new query: ```text var docs = db.From("docs"); await docs.InsertAsync(new { title = "Night at the oasis", year = 2024 }); var query = docs.Select("title", "year").Where("year", ">=", 2020).Order("year", "desc").Limit(5); var rows = await query.RowsAsync(); var hits = await docs.Select("title").Match("title", "oasys", fuzzy: 2).RowsAsync(); var n = await docs.Where("title", "~", "Dunes").CountAsync(); ``` .NET 8 and newer, async throughout with a `CancellationToken` on every call. `QueryAsync` maps rows to records or classes by property name, any case; `BatchAsync`, `WithIdempotencyKey`, `After` for a replica, `SubscribeAsync` as an `IAsyncEnumerable`, `ChangesAsync` and a `Tenant` option cover the rest of the [HTTP endpoint](https://fenecdb.com/docs/http) and its [reference](https://fenecdb.com/docs/http-reference) ([integrations/dotnet](https://github.com/fenecdb/fenecdb/tree/main/integrations/dotnet)); a refusal is a `FenecException`, and a batch's says which statement stopped it (`At`, from 0). A `float[]` or `ReadOnlyMemory` goes out as the decimals that read back as each float. Without the SDK, `HttpClient` and `System.Text.Json`: ```text var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:8080") }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "secret"); async Task Query(string query, params object[] args) { var json = JsonSerializer.Serialize(new { query, @params = args }); var res = await http.PostAsync("/query", new StringContent(json, Encoding.UTF8, "application/json")); var body = await res.Content.ReadFromJsonAsync(); if (!res.IsSuccessStatusCode) throw new HttpRequestException(body.GetProperty("error").GetString(), null, res.StatusCode); 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); var rows = await Query("get docs select title, year where year >= $1 order year desc limit $2", 2020, 5); var titles = rows.EnumerateArray().Select(r => r.GetProperty("title").GetString()); ``` The body is serialised whole: `PostAsJsonAsync` streams it chunked, and the server wants a `Content-Length`. [Full example](https://github.com/fenecdb/fenecdb/tree/main/examples/dotnet): Notes, a console app over the server with the builder, records and `SubscribeAsync`. ```text static final HttpClient HTTP = HttpClient.newHttpClient(); static HttpResponse 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(); ``` `java.net.http` alone, Java 11 and newer (text blocks need 15), run as `java Search.java`. The standard library has no JSON: the test compares the answer's text and reads the titles with a pattern, and a project reads the rows with Jackson or Gson. ```text 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']); ``` The curl extension and `json_encode`; Guzzle sends the same request. ```text 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"]) ``` ```text .package(url: "https://github.com/fenecdb/fenecdb", from: "0.1.12") // product "FenecDB" ``` ```swift import FenecDB // Embedded: the database is a file the app holds, no server. let db = try await Fenec.open(path: path) 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) // rows[0]["title"] == "Night at the oasis", rows[0]["year"] == 2024 let hits = try await db.query("get docs select title match title $1 fuzzy", "oasys") // hits[0]["title"] == "Night at the oasis", hits[0]["_score"] its score do { _ = try await db.query("get nowhere") } catch let e as FenecError { print(e.code, e) // notFound: collection `nowhere` ... } ``` ```swift let docs = try db.from("docs") try await docs.insert(["title": "Night at the oasis", "year": 2024] as Value) let recent = try await docs.select("title", "year").where("year", ">=", 2020).order("year", "desc").limit(5).rows() let found = try await docs.select("title").match("title", "oasys", fuzzy: 2).rows() let n = try await docs.where("title", "~", "Dunes").count() ``` ```swift // No file: every query goes to the server, through the same builder. let remote = Fenec.connect(url: "http://127.0.0.1:8080", token: "secret") try await remote.execute("put docs {title: $1, year: $2}", "Dunes", 2021) let recent = try await remote.from("docs").select("title", "year").where("year", ">=", 2020).order("year", "desc").limit(5).rows() ``` Rows decode as any `Decodable`, and a `[Float]` goes to the library as its bytes. A live query for SwiftUI, where the file goes and a replica a server keeps in step are on [Mobile and native apps](https://fenecdb.com/docs/mobile). A device reaches a server through TLS: `URLSession` makes the requests; over it, `remote.batch([...], idempotencyKey:)` lands statements whole and `withIdempotencyKey` makes a write once ([Without a file](https://fenecdb.com/docs/mobile#connect)). [Full example](https://github.com/fenecdb/fenecdb/tree/main/examples/swift): Notes, a SwiftUI app over a file, its list a `LiveQuery`, and a CLI. ```text implementation("com.fenecdb:fenecdb-android:0.1.12") // the AAR; the JVM: com.fenecdb:fenecdb ``` ```kotlin import com.fenecdb.* // Embedded: the database is a file the app holds, no server. val db = Fenec.openAsync(path) 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) // rows[0].string("title") == "Night at the oasis", rows[0].int("year") == 2024 val hits = db.query("get docs select title match title \$1 fuzzy", "oasys") // hits[0].string("title") == "Night at the oasis", hits[0].double("_score") its score try { db.query("get nowhere") } catch (e: FenecException) { println("${e.code} ${e.message}") // NOT_FOUND collection `nowhere` ... } ``` ```kotlin val docs = db.from("docs") docs.insert(mapOf("title" to "Night at the oasis", "year" to 2024)) val recent = docs.select("title", "year").where("year", ">=", 2020).order("year", "desc").limit(5).rows() val found = docs.select("title").match("title", "oasys", fuzzy = 2).rows() val n = docs.where("title", "~", "Dunes").count() ``` ```kotlin // No file: every query goes to the server, through the same builder. val remote = Fenec.connect("http://127.0.0.1:8080", token = "secret") remote.execute("put docs {title: \$1, year: \$2}", "Dunes", 2021) val recent = remote.from("docs").select("title", "year").where("year", ">=", 2020).order("year", "desc").limit(5).rows() ``` The calls suspend, on `Dispatchers.IO`; Java calls the `...Blocking` ones. Live queries are `Flow`s, and a replica a server keeps in step is `Fenec.sync` ([Mobile and native apps](https://fenecdb.com/docs/mobile)). A device reaches a server through TLS: `HttpURLConnection` makes the requests; over it, `remote.batch(listOf(...), idempotencyKey = id)` lands statements whole and `withIdempotencyKey` makes a write once ([Without a file](https://fenecdb.com/docs/mobile#connect)). [Full example](https://github.com/fenecdb/fenecdb/tree/main/examples/kotlin-android): Notes, a Compose app over a file, its list a live `Flow`, and a JVM CLI. ```bash flutter pub add fenecdb_flutter # Dart alone: dart pub add fenecdb ``` ```dart import 'package:fenecdb/fenecdb.dart'; // Embedded: the database is a file the app holds, no server. final db = await Fenec.open(path); 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]); // rows[0]['title'] == 'Night at the oasis', rows[0]['year'] == 2024 final hits = await db.query(r'get docs select title match title $1 fuzzy', ['oasys']); // hits[0]['title'] == 'Night at the oasis', hits[0]['_score'] its score try { await db.query('get nowhere'); } on FenecException catch (e) { print('${e.code.name} ${e.message}'); // notFound collection `nowhere` ... } ``` ```dart final docs = db.from('docs'); await docs.insert({'title': 'Night at the oasis', 'year': 2024}); final recent = await docs.select(['title', 'year']).where('year', '>=', 2020).order('year', 'desc').limit(5).rows(); final found = await docs.select(['title']).match('title', 'oasys', fuzzy: true).rows(); final n = await docs.where('title', '~', 'Dunes').count(); ``` ```dart // No file: every query goes to the server, through the same builder. final remote = Fenec.connect('http://127.0.0.1:8080', token: 'secret'); await remote.execute(r'put docs {title: $1, year: $2}', ['Dunes', 2021]); final recent = await remote.from('docs').select(['title', 'year']).where('year', '>=', 2020).order('year', 'desc').limit(5).rows(); ``` Every call runs on an isolate the database keeps, so none blocks the UI. Live queries are `Stream`s, and a replica a server keeps in step is `Fenec.openSynced` ([Mobile and native apps](https://fenecdb.com/docs/mobile)). A device reaches a server through TLS: `dart:io`'s `HttpClient` makes the requests; over it, `remote.batch([...], idempotencyKey: id)` lands statements whole and `withIdempotencyKey` makes a write once ([Without a file](https://fenecdb.com/docs/mobile#connect)). [Full example](https://github.com/fenecdb/fenecdb/tree/main/examples/flutter): Notes, a Flutter app over a file, its list a `StreamBuilder` over a live query. ## The builder Python, JavaScript, Go, .NET, Swift, Kotlin and Dart each have a query builder: a chain of calls that writes the FenecQL statement, every value a parameter and every name checked against FenecQL's own rule, which is where an injection would have to get through. All seven build the same text from the same chain, to the byte, and refuse the same mistakes with the same message. Python's builder starts with `collection`, since `from` is a keyword there. A short tour, the same in every tab, over two collections: ```fenecql create collection docs (slug text @unique, title text @text, year int, lang text, tags [text], views int) create collection comments (doc_id int @hash, body text) ``` A page of documents, filtered by a comparison and an `or`, in Turkish order, each with its first three comments; a search that forgives a misspelt word; one document and a count; then the writes — an insert, an insert passed over because its slug is held (`slug` is `@unique`), a counter moved and a delete by a filter. `db` is the client each language's section above connects, and Go's `ctx` a context. The FenecQL tab is what each chain writes; the block under the tabs is what it sends, its parameters apart. ```fenecql get docs where year >= 2024 and (lang = "tr" or tags has "rust") order title collate tr asc limit 20 lookup comments on doc_id limit 3 get docs where year >= 2024 match title "databse" fuzzy 2 limit 10 get docs where slug = "intro" limit 1 get docs where year >= 2024 count put docs {slug: "intro", title: "Ownership in Rust", year: 2026} put docs {slug: "intro", title: "Ownership in Rust"} if absent set docs {views: coalesce(views, 0) + 1} where slug = "intro" del docs where year < 2000 ``` ```js await db.from('docs') .where('year', '>=', 2024) .where(or({ lang: 'tr' }, { tags: { has: 'rust' } })) .order('title', 'asc', { collate: 'tr' }) .limit(20) .lookup('comments', { on: 'doc_id', limit: 3 }) .rows(); await db.from('docs') .where('year', '>=', 2024) .match('title', 'databse', { fuzzy: true }) .limit(10) .rows(); await db.from('docs').where('slug', 'intro').first(); await db.from('docs').where('year', '>=', 2024).count(); await db.from('docs') .insert({ slug: 'intro', title: 'Ownership in Rust', year: 2026 }); await db.from('docs') .insert({ slug: 'intro', title: 'Ownership in Rust' }, { ifAbsent: true }); await db.from('docs').where('slug', 'intro').update({ views: inc(1) }); await db.from('docs').where('year', '<', 2000).delete(); ``` ```python (db.collection("docs") .where("year", ">=", 2024) .where(or_({"lang": "tr"}, {"tags": {"has": "rust"}})) .order("title", "asc", collate="tr") .limit(20) .lookup("comments", on="doc_id", limit=3) .rows()) (db.collection("docs") .where("year", ">=", 2024) .match("title", "databse", fuzzy=True) .limit(10) .rows()) db.collection("docs").where("slug", "intro").first() db.collection("docs").where("year", ">=", 2024).count() (db.collection("docs") .insert({"slug": "intro", "title": "Ownership in Rust", "year": 2026})) (db.collection("docs") .insert({"slug": "intro", "title": "Ownership in Rust"}, if_absent=True)) db.collection("docs").where("slug", "intro").update({"views": inc(1)}) db.collection("docs").where("year", "<", 2000).delete() ``` ```go db.From("docs"). Where("year", ">=", 2024). WhereCond(fenecdb.Or(fenecdb.Fields("lang", "tr"), fenecdb.Cmp("tags", "has", "rust"))). Order("title", "asc", fenecdb.Collate("tr")). Limit(20). Lookup("comments", fenecdb.On("doc_id"), fenecdb.Limit(3)). Rows(ctx) db.From("docs"). Where("year", ">=", 2024). Match("title", "databse", fenecdb.Fuzzy(2)). Limit(10). Rows(ctx) db.From("docs").Where("slug", "=", "intro").First(ctx) db.From("docs").Where("year", ">=", 2024).Count(ctx) db.From("docs"). Insert( ctx, fenecdb.D("slug", "intro", "title", "Ownership in Rust", "year", 2026), ) db.From("docs"). Insert( ctx, fenecdb.D("slug", "intro", "title", "Ownership in Rust"), fenecdb.IfAbsent(), ) db.From("docs"). Where("slug", "=", "intro"). Update(ctx, fenecdb.D("views", fenecdb.Inc(1))) db.From("docs").Where("year", "<", 2000).Delete(ctx) ``` ```csharp await db.From("docs") .Where("year", ">=", 2024) .Where(Cond.Or(Cond.Cmp("lang", "=", "tr"), Cond.Cmp("tags", "has", "rust"))) .Order("title", "asc", collate: "tr") .Limit(20) .Lookup("comments", on: "doc_id", limit: 3) .RowsAsync(); await db.From("docs") .Where("year", ">=", 2024) .Match("title", "databse", fuzzy: 2) .Limit(10) .RowsAsync(); await db.From("docs").Where("slug", "intro").FirstAsync(); await db.From("docs").Where("year", ">=", 2024).CountAsync(); await db.From("docs") .InsertAsync(new { slug = "intro", title = "Ownership in Rust", year = 2026 }); await db.From("docs") .InsertAsync( new { slug = "intro", title = "Ownership in Rust" }, ifAbsent: true ); await db.From("docs") .Where("slug", "intro") .UpdateAsync(new { views = Computed.Inc(1) }); await db.From("docs").Where("year", "<", 2000).DeleteAsync(); ``` ```swift try await db.from("docs") .where("year", ">=", 2024) .where(.or(["lang": "tr"], .cmp("tags", "has", "rust"))) .order("title", "asc", collate: "tr") .limit(20) .lookup("comments", on: "doc_id", limit: 3) .rows() try await db.from("docs") .where("year", ">=", 2024) .match("title", "databse", fuzzy: 2) .limit(10) .rows() try await db.from("docs").where("slug", "intro").first() try await db.from("docs").where("year", ">=", 2024).count() try await db.from("docs") .insert(["slug": "intro", "title": "Ownership in Rust", "year": 2026] as Value) try await db.from("docs") .insert( ["slug": "intro", "title": "Ownership in Rust"] as Value, ifAbsent: true ) try await db.from("docs") .where("slug", "intro") .update(["views": .inc(1)] as Value) try await db.from("docs").where("year", "<", 2000).delete() ``` ```kotlin db.from("docs") .where("year", ">=", 2024) .where(Cond.or(mapOf("lang" to "tr"), Cond.cmp("tags", "has", "rust"))) .order("title", "asc", collate = "tr") .limit(20) .lookup("comments", on = "doc_id", limit = 3) .rows() db.from("docs") .where("year", ">=", 2024) .match("title", "databse", fuzzy = 2) .limit(10) .rows() db.from("docs").where("slug", "intro").first() db.from("docs").where("year", ">=", 2024).count() db.from("docs") .insert(mapOf("slug" to "intro", "title" to "Ownership in Rust", "year" to 2026)) db.from("docs") .insert( mapOf("slug" to "intro", "title" to "Ownership in Rust"), ifAbsent = true ) db.from("docs") .where("slug", "intro") .update(mapOf("views" to Computed.inc(1))) db.from("docs").where("year", "<", 2000).delete() ``` ```dart await db.from('docs') .where('year', '>=', 2024) .where(Cond.or([{'lang': 'tr'}, Cond.cmp('tags', 'has', 'rust')])) .order('title', 'asc', 'tr') .limit(20) .lookup('comments', on: 'doc_id', limit: 3) .rows(); await db.from('docs') .where('year', '>=', 2024) .match('title', 'databse', fuzzy: true) .limit(10) .rows(); await db.from('docs').where('slug', 'intro').first(); await db.from('docs').where('year', '>=', 2024).count(); await db.from('docs') .insert({'slug': 'intro', 'title': 'Ownership in Rust', 'year': 2026}); await db.from('docs') .insert({'slug': 'intro', 'title': 'Ownership in Rust'}, ifAbsent: true); await db.from('docs') .where('slug', 'intro') .update({'views': Computed.inc(1)}); await db.from('docs').where('year', '<', 2000).delete(); ``` ```fenecql get docs where year >= $1 and (lang = $2 or tags has $3) order title collate tr asc limit $4 lookup comments on doc_id limit $5 -- [2024, "tr", "rust", 20, 3] get docs where year >= $1 match title $2 fuzzy 2 limit $3 -- [2024, "databse", 10] get docs where slug = $1 limit $2 -- ["intro", 1] get docs where year >= $1 count -- [2024] put docs {slug: $1, title: $2, year: $3} -- ["intro", "Ownership in Rust", 2026] put docs {slug: $1, title: $2} if absent -- ["intro", "Ownership in Rust"] set docs {views: coalesce(views, 0) + $1} where slug = $2 -- [1, "intro"] del docs where year < $1 -- [2000] ``` Each has `select` with aggregates and `group`, `where` with every operator, `in`, `null`, paths into a json field and `or`, `and`, `not` and `raw`; `match`; `near`, a vector's nearest rows (`near('embed', v, { ef: 64 })` in JavaScript, [Vector search](https://fenecdb.com/docs/fenecql-vector)), and `fuse` and `rerank`, which rank by the words and a vector together; `highlight`, `snippet` and `facet`, the facets beside the rows; `lookup` and its chains; `order` with a collation; `limit` and `offset`; and the writes, an `update` or `delete` with no filter refused unless it says `all`, a value worked out over the row with `inc` and `expr` ([Writes that read the row](https://fenecdb.com/docs/fenecql-writing#writes)), and an insert `if absent`. A query is immutable, so a base query can be kept and branched from. Rows come back as each language holds a document, and three builders also decode them into a type of the app's: Go's `fenecdb.RowsAs[T](ctx, q)`, .NET's `RowsAsync()` and Swift's `rows(as: T.self)`. Go's endpoints take the `ctx` that bounds the request, .NET's are `…Async`, and Kotlin's suspend, with `rowsBlocking()` and `countBlocking()` for code outside a coroutine. How one golden file holds every builder `integrations/builder-golden.json` is the contract: each case a chain of steps (`{op, args}`, the JavaScript builder's names) and the statement it must make, its text and parameters, or the refusal and its message. The JavaScript builder is the reference: `make builder-golden` runs every chain through it and writes what it made, and its own tests run the file again, so the two cannot drift. The Python, Go, .NET, Swift, Kotlin and Dart tests each read every case, call their own builder, and compare the text and parameters exactly. The cases cover every method and option, each operator and kind of value, parameter numbering across `where`, `near`, `match` and the levels of a lookup chain, and the names and combinations each builder refuses. Each SDK's tests also run builder queries against a real server and hold their answers to the same statements sent as text. The tour above and the tabs of the FenecQL reference's examples are cases of the file written in each language ([Text search](https://fenecdb.com/docs/fenecql-search#the-text-clause) has some): the JavaScript and Python tabs run as the page shows them, and each other language's tests hold a copy of its tabs' code, so a tab is code that sends the statement beside it. What a language has no word for is written its own way: Go's object conditions are `Fields` and `Ops`, names and values in turn, since a Go map has no order and the order is the text's; a Go document keeps its order as a `fenecdb.D(...)`, and a map's fields go in sorted. Options are keyword arguments in Python, named arguments in C# and functional options in Go, where a struct could not tell `limit 0` from no limit. A refusal is a `FenecError` with status 0 in Python, a `*fenecdb.QueryError` in Go and a `FenecQueryException` in .NET. Swift's dictionaries have no order either, so a document or an object condition is a `Value` or `Row` literal, which keeps the order written, or a `Codable` type, its fields in the order it encodes them; Kotlin's and Dart's maps keep their order. A refusal there is a `FenecError`, a `FenecException` and a `FenecException`, each with the code `builder`, and every builder method throws as it is called, so a Swift chain takes one `try`. ## From the shell The same request from `curl`: ```bash 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}] ``` The REST routes, subscriptions and the change stream are on [HTTP reference](https://fenecdb.com/docs/http-reference), and batches on [HTTP endpoint](https://fenecdb.com/docs/http). What the examples and SDK tests run against The programs under `integrations/languages` are the examples above with their asserts: `make languages-test` starts a `fenec-server` over a new file for each language and runs Python, Java, PHP and Ruby from their official images in Docker, and JavaScript, Go, C# and Rust with the toolchains on the machine — Go and C# both through their SDK and without it, and each SDK's program through its query builder too. The SDKs' own tests (`make go-test`, `make dotnet-test`) start a primary, a replica of it and a node of tenants and cover the rest: every type of parameter, the errors, a batch put back whole, an idempotency key, reading a write on the replica, a subscription, the change stream, a tenant and a scoped token. CI runs it on every change, so a change to the HTTP endpoint that would break one of them is caught before it ships. The JavaScript tab's programs are TypeScript, and run there as the JavaScript they are without their types; the tab's code itself is type-checked under `tsc --strict` against the client's declarations, as every TypeScript example in these docs is (`make docs-types`). Every answer is JSON: a read is an array of rows, a ranked search's rows (`match`, `near`) carry their `_score`, a write is `{"affected": n}`, and a refusal is a status of 400 and up with `{"error": "..."}` — 401 for a missing or wrong token, 404 for a collection that is not there, 409 for an id `insert` finds taken. A vector goes in as a JSON array of numbers and comes out as the shortest text that reads back as each `f32`. Values always go in `params`, never into the statement's text. The server keeps a connection open between requests (HTTP/1.1 keep-alive) for a client that reuses it, as Go's, .NET's, Java's and ureq's do. A body is read by its `Content-Length`; a chunked one is refused. --- Source: https://fenecdb.com/docs/javascript # JavaScript and TypeScript Dependency-free ES modules run fenecdb in the page: open a database, query it, and keep it between visits — or query a server through the client alone. Their types come with them. The shortest way in: install the package, open the module and query it. This is the script of [Quickstart: in the browser](https://fenecdb.com/docs/quickstart-browser), a Vite project that you can follow from an empty folder: ```bash npm install @fenecdb/web ``` ```js import { Fenec } from '@fenecdb/web'; import wasm from '@fenecdb/web/fenec.wasm?url'; // Vite hands over the module's URL const db = await Fenec.open(wasm); db.run('create collection notes (title text @text, year int @sorted)'); db.run('put notes {title: $1, year: $2}', ['Dunes', 2021]); db.run('put notes {title: $1, year: $2}', ['Night at the oasis', 2024]); const { rows } = db.run('get notes where year >= $1 order year desc', [2020]); // [{"id":2,"title":"Night at the oasis","year":2024},{"id":1,"title":"Dunes","year":2021}] const hits = db.rows('get notes select title match title $1 fuzzy', ['oasys']); // [{"title":"Night at the oasis","_score":0.62239116}] ``` That database is in the tab's memory, with no server; Opening a database below keeps it between visits, and the next section says when to load the module and when to query a server through the client alone. ## Which package Pick by where the queries run: on a server, through the client entry and no module, or in the page, through the full module. A synced replica loads the same module, so its `near` walks a graph as a database in the page does. | For | What it imports and loads | Brotli | What it can do | What it cannot | | Queries on a server | `@fenecdb/web/client`: `connect`, no module | 9 KB | every query and write over HTTP through the builder, live queries over the server's subscriptions, the code's schema checked against the server's | answer without the network, or keep data in the page | | A database in the page, or a synced replica | `Fenec.open('./fenec.wasm')`; `sync()` loads it too | 273 KB | everything: the graph, every index, the schema check, persistence and files | — | Beside a module the page loads `fenec.js` and the two modules it imports, 51 KB brotli as they are served with no build step; a bundler keeps what the app uses of them. An app whose queries run on a server can import the client entry instead: ```ts import { connect } from '@fenecdb/web/client'; // or './client.js', beside builder.js and http.js const db = connect('https://db.example.com', { token: 'a token' }); const rows = await db.from('docs').where('year', '>=', 2024).limit(10).rows(); const stop = db.live(db.from('docs').where('year', 2024), (rows) => console.log(rows.length)); // again after every write it may read ``` - **What the entry guarantees** is that it cannot pull in the engine, the sync layer or storage. The bytes it saves are few: a page that only calls `connect` never downloaded the module through `@fenecdb/web` either, since only `Fenec.open` fetches it. - **The same builder:** a query moves between the client, a replica and a database in the page unchanged, and so do its types. - **A live query over the server** runs the query there again after a write to a collection it reads; a FenecQL text names those with `{ collections }`. What each entry bundles, and how a live query over a server runs `@fenecdb/web/client` is `client.js`, which re-exports the builder (`builder.js`) and the HTTP client (`http.js`), the two modules `fenec.js` imports beside its glue, persistence and the sync layer. It reaches nothing else — no WebAssembly, no IndexedDB, no files — and a test fails if it does. An app that calls `connect` and reads a builder query's rows bundles to 9 KB brotli through it and to 12 KB through `@fenecdb/web`, whose `Fenec` class and its glue a bundler cannot drop (esbuild, minified): 2.5 KB of JavaScript, and no `.wasm` either way. Its live queries open a subscription to each collection the query reads (`GET //changes`), of a shape that holds no row: the seed is empty, and every write to the collection is a change that runs the query again on the server. The first rows wait for every stream's seed, a burst of writes runs it once after the run under way, and a stream that ends is opened again, its seed running the query, since writes may have come between. `useLiveQuery` takes the endpoint as it takes a replica. Under a scoped token the server tells such a stream of the writes to rows the token may read, and of no other ([HTTP](https://fenecdb.com/docs/http-reference#subscriptions)). A token refused, or lapsed under an open stream -- the server ends a stream at its token's `exp` -- is a `FenecError` of status 401 to `onError`, and the live query stops there, for the app to open it again with a fresh token. Each way in the table has a full example in the repository, the same Notes app: queries on a server from Node through the client entry ([node-server](https://github.com/fenecdb/fenecdb/tree/main/examples/node-server)), a database in the page kept in IndexedDB ([web-local](https://github.com/fenecdb/fenecdb/tree/main/examples/web-local)), and a React app over either that or a synced replica ([react](https://github.com/fenecdb/fenecdb/tree/main/examples/react)). A larger one is a shop in Next.js ([shop](https://github.com/fenecdb/fenecdb/tree/main/examples/shop)): 10 000 products whose pages the server renders through the client entry, search with highlights and facets, carts under each shopper's token, and a checkout that cannot oversell, with its Lighthouse scores, timings and load test measured. Another is a payments ledger on Node ([ledger](https://github.com/fenecdb/fenecdb/tree/main/examples/ledger)): transfers, refunds and holds as guarded `/batch` blocks, an append-only journal, reconciliation and a change-stream sink, held to its invariants by 16 clients, a crash under `kill -9` and its security tests. A third is courier dispatch in Next.js ([dispatch](https://github.com/fenecdb/fenecdb/tree/main/examples/dispatch)): a company a tenant behind `fenec-shard` with its plan's quota, orders claimed by the nearest courier under a lease or held at the server until one comes, couriers on a live map, and couriers' and customers' tokens held to their own rows, with the gaps it found listed. ### A shape's rows, as they change `db.subscribe` keeps a shape -- the rows of a collection a filter selects -- rather than a query's answer: a `seed` with the whole shape, then each `change`, the rows written into it and the ids that left it. The shape is the REST surface's: a field's filter, `where` and `select`. ```ts import { connect } from '@fenecdb/web/client'; const db = connect('https://db.example.com/t/acme', { token: 'a token' }); const tasks = new Map(); const stop = db.subscribe('tasks', { team: 'eq.design', select: ['title', 'status'] }, (ev) => { if (ev.type === 'seed') { tasks.clear(); for (const r of ev.rows) tasks.set(r.id, r); } else { for (const r of ev.puts) tasks.set(r.id, r); for (const id of ev.dels) tasks.delete(id); } }, { onError: (e) => console.warn(e), // status 401: a fresh token, then subscribe again onState: (s) => console.log(s), // 'open' at each seed, 'retry' while it waits }); ``` A stream that ends -- a tenant moved, the network -- is opened again (250 ms, doubling to 15 s) and seeds again; one refused or ended for its token is not. `sseEvents(res)`, the reader under it, takes any `fetch` answer of server-sent events: `EventSource` cannot send `Authorization`. ### Batches, retries and polling `db.batch` sends statements as one `/batch`: every write lands or none does. Each is a builder query, a FenecQL text, or `[text, params]` as `toInsert`, `toUpdate` and `toDelete` make them; with an `idempotencyKey` a retry after a timeout is answered as the first was. ```ts import { connect, inc, FenecError } from '@fenecdb/web/client'; const db = connect('https://db.example.com', { token: 'a token' }); const stock = db.from('inventory'); try { const { results, replayed } = await db.batch( [ stock.where('sku', 'TENT-1').where('available', '>=', 2).toUpdate({ available: inc(-2) }, { require: 1 }), db.from('orders').toInsert({ number: 'SG-1', sku: 'TENT-1', qty: 2 }), ], { idempotencyKey: 'checkout-7f3a' }, ); console.log(results, replayed); } catch (e) { // 412: the statement at `at` wrote no row where it required one, and nothing landed. if (e instanceof FenecError && e.status === 412) console.log('sold out', e.at); } await db.run('put orders {number: $1}', ['SG-2'], { idempotencyKey: 'order-SG-2' }); await db.withIdempotencyKey('order-SG-3').from('orders').insert({ number: 'SG-3' }); // No stream held: the server answers 304, not running the query, while nothing it reads was written. const stop = db.live(stock.select('available').where('sku', 'TENT-1'), (rows) => console.log(rows[0]), { poll: 5000 }); ``` - **A refusal names its statement:** `FenecError`'s `at` (from 0), its `status` (412 a `require` not met, 409 a value taken, 422 a key sent with another request) and `completed`. A database in the page names it the same way: `db.run` of several statements, one block, throws with the `at` of the one that stopped it, and `db.batch` there takes the same items and answers as a server's, each statement with its own parameters. - **A write answers where it left the database,** `seq`, and the client keeps the last as `db.seq`. - **`{ poll }` is for a page many view:** a stream is a server thread and a wake-up at every write, a poll holds nothing between rounds ([Polling a read](https://fenecdb.com/docs/http-reference#polling)). - **`{ wait: ms }` holds a claim at the server** until a job comes, on `update`, `delete`, `run` and `batch`, and `{ signal }` gives it up ([Waiting for a job](https://fenecdb.com/docs/queues#wait)). A database in the page refuses it: there is no other worker to wait for. - **`connect(url, { reads: 'nearest' })` reads near:** the builder's reads, and a `run` or `batch` given `{ read: true }`, are answered by the tenant's read replica in the router's region, each after the furthest position an answer from home named to this client, its change and its history — it reads nothing older than it wrote, nor a write a failover replaced; writes and subscriptions go home. A `live` query's streams stay home and each of its runs is a read near, sent after the change its stream told of and the rows it last showed too, so it shows every write home takes and never goes back; its polls go home (Read replicas). A write sent with `{ read: true }` is refused, 403. ## Opening a database Copy `fenec.js`, the `builder.js` and `http.js` it imports, and `fenec.wasm` next to your page, open the module, and run FenecQL or the query builder. `persist` keeps the database in IndexedDB and `restore` reads it back. ```ts import { Fenec, persist, restore } from './fenec.js'; const db = await Fenec.open('./fenec.wasm'); db.run('create collection docs (title text, embed vector<384> @hnsw(cosine))'); db.run('put docs {title: $1, embed: $2}', ['hello', embedding]); const { rows } = db.run('get docs near embed $1 limit 5', [queryVector]); const titles = await db.from('docs').select('title').limit(10).rows(); // the builder await persist(db); // into IndexedDB: the image once, then only the writes since await restore(db); // read it back ``` - **The examples are TypeScript.** The same code runs as plain JavaScript, and the module needs no build step: `fenec.d.ts` beside it holds the types ([TypeScript](#typescript)). - **`db.run()` is synchronous;** the builder's endpoints return promises. - **Text in a collation** also needs the `collate/` directory beside the module ([below](#collation)). The four files, and why run() is synchronous `web/fenec.js` is a dependency-free ES module: the WebAssembly glue, persistence and the sync layer, with the query builder and the HTTP client in the two modules it imports and re-exports. Copy four files — and the `collate/` directory beside the module, for text in a collation — and you are done. `db.run()` is synchronous because raw FenecQL is a single wasm call. The builder's endpoints return promises, because a builder has to pick its transport first. ## Keeping a database In a dedicated worker, `openFile` keeps the database in a file of the origin private file system — the same file `fenec-server` keeps — and each write is flushed before `run` returns. `persist` and `restore` keep the same bytes in IndexedDB, and work anywhere a page runs. ```ts // in a dedicated worker import { Fenec, openFile } from './fenec.js'; const db = await Fenec.open('./fenec.wasm'); const file = await openFile(db, 'notes.fenec'); // loads what it holds, or takes the database's image db.run('put notes {text: $1}', ['in the file before run returns']); const bytes = file.bytes(); // what fenec and fenec-server open ``` - **The file is portable:** download it and `fenec notes.fenec` opens it, and a server's file loads in a page. - **One owner at a time:** a second worker or tab that opens the file is refused. - **A crash leaves a whole file:** the next `openFile` holds every write `run` answered. How openFile works, with timings `openFile` keeps the database in a file of the origin private file system, byte for byte the file `fenec-server` keeps on disk: download it and `fenec notes.fenec` opens it, and a server's file handed to a page loads there. `run` appends each statement's writes to it and flushes the file before it answers. It needs a dedicated worker, the one place a page gets synchronous access to a file, and it takes the file for itself: a second worker or tab that opens the file is refused, where a second process over a server's file would corrupt it. A `compact`, and writes appended past half the image (and past 64 KB), put a new image in the file. It is written into a copy beside the file (`notes.fenec~`) and flushed before the file is touched, so a crash at any point leaves one of the two whole, and the next `openFile` takes whichever holds every write `run` answered. A write a crash cut short is cut off. A new image holds the live records alone, so what updates and deletes left behind goes with it, from the file and from `persist`'s images alike; the page's memory keeps those versions until it opens the file again or runs `compact`, since a page has no thread to compact beside its queries the way a server and an app do. Once the file refuses a write, every later write is refused too; the database has to be opened from the file again. `persist` and `restore` keep the same bytes in IndexedDB, wherever a page runs: the image once, then only the writes since the last call, as chunks. `persist` stores what was written when it is called, and IndexedDB decides when that reaches the disk; the file is flushed before every answer. Over 32.4 MB (61 000 rows with a `vector<128>`), in a worker (`make file-bench`): | | IndexedDB, Chrome 153 | File, Chrome 153 | IndexedDB, Safari 26.1 | File, Safari 26.1 | | the first image | 92 ms | 59 ms | 418 ms | 156 ms | | a new row stored, p50 | 0.18 ms | 0.56 ms | 0.98 ms | 0.36 ms | | opened again | 47 ms | 20 ms | 190 ms | 118 ms | The file's row includes its statement, 0.03 ms in Chrome and 0.10 ms in Safari, its vector handed over as `f32`s, and its flush. Firefox was not measured. ## Sealed in IndexedDB Pass a WebCrypto AES-GCM key and `persist` encrypts what it stores; `restore` then needs the same key. Where the key comes from is up to the page — never keep it in IndexedDB beside the data. ```ts import { persist, restore } from './fenec.js'; // A key from a passphrase, never stored: the page asks for it again. const salt = new TextEncoder().encode('myapp:' + userId); const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']); const cryptoKey = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); await persist(db, 'notes', { cryptoKey }); // the image and each chunk sealed await restore(db, 'notes', { cryptoKey }); // another key, or none, is refused ``` What the seal covers, and what it costs With a `cryptoKey` (AES-GCM, from WebCrypto) the image and every chunk are sealed before they reach IndexedDB, each with a random nonce and a tag over the key it is stored under and its place. A chunk moved, dropped from the middle, or left from an image before is refused, as is one byte changed. A restore without the key refuses a sealed record, and a restore with one refuses a record stored in the clear. Where the key comes from is the page's decision: derived from a passphrase, as above, or handed over by the server after a login, so that what IndexedDB holds on the disk reads as nothing without it. A key kept in IndexedDB beside the data protects nothing. The sync layer takes it as `sync({ persist, cryptoKey })`. Its cursors and unsent writes are collections of the replica, sealed with it. The same 32.4 MB in Chrome 154: the first image 129 ms against 76, a new row 0.16 ms p50 either way, a restore 37 ms against 29. Only the image pays: it is 32 MB to seal. The file of `openFile` stays plain, since it is the file `fenec-server` opens. The disk's own encryption covers it. ## State in the page A database in the page can hold an app's whole state, with no server. Declare its collections in code, and the open makes them. `db.live(query, fn)` hands `fn` the query's rows now, and again after every write to a collection the query reads. Event handlers write, and `persist` keeps the state between visits. ```ts import { Fenec, persist, restore } from './fenec.js'; import { fenecTable, text, boolean, timestamp, index } from './schema.js'; export const todos = fenecTable('todos', { key: text(), title: text().notNull(), done: boolean(), at: timestamp(), }, (t) => [ index('todos_key').using('hash', t.key), index('todos_done').using('hash', t.done), index('todos_at').on(t.at), ]); const db = await Fenec.open('./fenec.wasm', { schema: { todos } }); await restore(db, 'todos'); // the stored database in its place, checked against todos // the rows now, and again after every write to todos const stop = db.live(db.from(todos).where('done', false).order('at'), draw); async function add(title: string) { await db.from(todos).insert({ title, done: false, at: new Date() }); await persist(db, 'todos'); // only this write, not the whole image } async function finish(id: number) { await db.from(todos).where('id', id).update({ done: true }); await persist(db, 'todos'); } ``` In React, `useLiveQuery` from `@fenecdb/react` takes the same database, and the rows it hands back are typed by the table: ```ts import { FenecProvider, useFenec, useLiveQuery } from '@fenecdb/react'; import type { Fenec, SchemaOf } from './fenec.js'; import { todos } from './tables.js'; // the tables above, in a module of the app's type Db = Fenec>; function Todos() { const db = useFenec(); const open = useLiveQuery(db.from(todos).where('done', false).order('at')); if (open === undefined) return null; return open.map((t) => (
  • db.from(todos).where('id', t.id).update({ done: true })}>{t.title}
  • )); } root.render(); ``` To share the todos through a server later, open a [synced replica](https://fenecdb.com/docs/sync) instead. The components stay as they are: a replica has the same `from` and `live`, and its writes reach the server. The server owns the schema there: the tables are checked against it, and nothing is applied. ```ts import { sync } from './fenec.js'; import { FenecProvider } from '@fenecdb/react'; import { todos } from './tables.js'; const db = await sync({ url: 'https://todos.example.com', schema: { todos }, shapes: [{ collection: todos, key: 'key' }], }); root.render(); ``` The whole of it is the repository's [react](https://github.com/fenecdb/fenecdb/tree/main/examples/react) example: its database opens in the page or as a replica by one line of `src/db.ts`, and the components are the same. - **One run per task:** the writes made in one task, such as a `run` of several statements or a loop, run each live query once. - **Only what it reads:** a query built with `db.from` knows its collections, including each `lookup`'s. A text names them: `db.live('get todos count', fn, { collections: ['todos'] })`. - **A restore runs them all:** `restore`, `openFile` and `load` hand every live query the loaded rows. - **`live` returns its stop function.** An error goes to `{ onError }`, or `db.onError`. How a live query learns what changed The engine notes every write in its change ring, a collection id and a document id each, and `fenec_changes(since)` names the collections written since a cursor. A block's writes are noted as it lands, so a text of several statements that fails names nothing. A `create`, an `alter` and a `create index` name their collection. A `drop` answers `null`, which means every collection: a dropped collection has no name left to give. Its live query runs again and gets the error that the collection is gone. Queries over other collections also run again for nothing, which is harmless. The notice costs a write nothing. A statement's answer does not carry it. A page with a live query asks once, in a microtask after the task that wrote: a write to a collection no live query reads costs that one call, about 2 µs in Node. Running a query again is the query itself: a page of 50 open todos out of 1 000 to 3 000, ordered by an `@sorted` field, took 0.19 ms. No incremental maintenance is needed ([Sync](https://fenecdb.com/docs/sync) says why). The module did not grow, and `make wasm-speed`'s `put` did not move. A synced replica runs its live queries at the next frame instead, so that a seed landing in chunks is not shown half done. Both share one implementation. A `load` runs every live query whatever the ring says, since the image's counter may stand where the cursor did. With `openFile`, the database and its live queries live in the dedicated worker that holds the file. The worker runs them, and posts the rows to the page that draws them. The page holds no database to subscribe to. The `key` field in the example is what a replica reconciles an optimistic insert by. It fills the field in for you. Without a key, the row shows up once the server has it, not at once. ## Schema in code Declare the collections in TypeScript, the way Drizzle declares tables, and open the database with them. The open compares the database with the code: what only adds is made, and anything that could lose data is refused, listed with how to resolve it. The tables also type every query. ```ts import { Fenec } from './fenec.js'; import { fenecTable, text, integer, json, vector, index, uniqueIndex, defineRelations } from './schema.js'; export const products = fenecTable('products', { name: text().notNull().collate('tr'), sku: text().unique(), meta: json(), embed: vector({ dimensions: 384 }), }, (t) => [ index('products_name').using('bm25', t.name).with({ prefix: 6 }), index('products_embed').using('hnsw', t.embed.op('vector_cosine_ops')).with({ m: 16 }), uniqueIndex('products_lang').on(t.meta.path('lang')), ]); export const reviews = fenecTable('reviews', { productId: integer().notNull(), stars: integer(), }, (t) => [index('reviews_product').using('hash', t.productId)]); export const relations = defineRelations({ products, reviews }, (r) => ({ products: { reviews: r.many.reviews({ from: r.products.id, to: r.reviews.productId }) }, })); const db = await Fenec.open('./fenec.wasm', { schema: { products, reviews }, relations }); type Product = typeof products.$inferSelect; // { id: number; name: string; sku: string | null; ... } const top = await db.from(products).select('name').lookup('reviews', { where: { stars: 5 } }).rows(); // top: { name: string; reviews: { id: number; productId: number; stars: number | null }[] }[] ``` - **Made or checked at every open.** An empty database is made as declared. One that exists gets what only adds — a collection, a field, an index where there is none — in one block. A `restore` or `openFile` checks the database it brings in. - **Refused, nothing is written.** A field the code lacks (dropped, or renamed?), a type changed, an index changed or taken off, a collation changed, a field made required: the open throws a `FenecError` that names each difference and how to resolve it. - **Migrations resolve them.** `migrations: [rename('todos', 'title', 'name')]` runs once, in order, recorded in the database's `_migrations`. - **A schema is FenecQL too.** `schema` also takes the `create collection` statements of a `schema.fenecql` file, which every fenecdb SDK can hand over. Declaring columns, indexes and relations A column builder takes no name: the key is the field's name. Columns are nullable unless `.notNull()`, FenecQL's `required`. A field takes one index, declared on the column (`.unique()`) or in the third argument; two on one field are refused as the table is declared, as is anything FenecQL cannot hold. An index over several fields is `.on()` with several columns, each `.desc()` or `.asc()` as Drizzle writes it, beside a field's own ([Indexes over several fields](https://fenecdb.com/docs/fenecql-indexes#composite)), and `dropIndex(table, ...fields)` is the migration that takes one off. | Declared | FenecQL | | `text()` `integer()` `doublePrecision()` `boolean()` | `text` `int` `float` `bool` | | `timestamp()` `bytea()` `json()` | `timestamp` `bytes` `json` | | `vector({ dimensions: 384 })` / `halfvec(...)` | `vector<384>` / `vector<384, f16>` | | `sparsevec({ dimensions: 30522 })` | `sparse<30522>` | | `text().array()` | `[text]` | | `.notNull()` `.collate('tr')` `.nocase()` `.unique()` | `required` `collate tr` `nocase` `@unique` | | `index().on(t.at)` | `@sorted` | | `index().on(t.at).ttl('30d')` | `@ttl(30d)` | | `index().using('hash', t.n)` / `uniqueIndex().on(t.n)` | `@hash` / `@unique` | | `index().on(t.owner, t.at.desc())` / `uniqueIndex().on(t.org, t.slug)` | `@sorted(owner, at desc)` / `@unique(org, slug)` | | `index().using('hnsw', t.e.op('vector_l2_ops')).with({ m, ef_construction, ef_search, quant })` | `@hnsw(l2, m=.., ...)` | | `index().using('bm25', t.body).with({ k1, b, prefix, prefix_min, chars })` | `@text(...)` | | `index().using('inverted', t.splade)` | `@inverted` | | `index().using('hash', t.meta.path('lang'))` | `create index on t (meta.lang) @hash` | `typeof products.$inferSelect` is a row as read, `id` and all, a nullable column `T | null`: the same type `fenec types` generates for the collection, which the tests hold it to field for field. `$inferInsert` is what `insert` takes: the `.notNull()` columns given, the rest optional. `db.from(table)`, `lookup(table, ...)`, a shape's `collection` and `useLiveQuery` are typed by the table, and a misspelt field or a wrong value's type is a compile error. A relation of `defineRelations` is a `lookup`: `on = `, its rows attached under the table it reaches. `to` must be `id` or a column with a hash or unique index, since `lookup` finds children by an index and never by a scan; a relation it cannot answer is refused as it is declared. `r.one` attaches the first child, still in a list. What the check does at an open The engine compares, in the module or on the server, so every SDK applies and refuses the same things with the same words. What it does: | Difference | At an open | Resolved by | | A collection, a field, an index where there is none, a path index | made | — | | A collection the code does not declare | left as it is | `dropTable('t')` to drop it | | A field the code does not declare | refused: dropped, or renamed? | `rename(...)` or `drop(...)` | | A type changed | refused | a migration: add a field, `set` it from the old, drop the old | | An index changed or taken off, a collation changed | refused | `rebuild('t', 'field')`; an expiry: `alter field` | | A required field made optional | refused | `rebuild`: made again, not required | | A field made required, or a new required field | refused | declare it as the database has it | ```text FenecError: the database's schema differs from the code's, and nothing was applied: - `todos.title` is in the database and not in the code if it was renamed to `name`, the migration `alter collection todos rename field title to name`; if it goes, `alter collection todos drop field title` ``` Everything an open applies is one block: the migrations not yet recorded, their records, and the additions land together, or — on a refusal or an error — none of them. A database made from the code records its migrations without running them, since they lead from schemas it never had. A migration is known by its place in the list and held to its text: one changed after it ran, or a database holding more migrations than the code lists — an older app opening what a newer one migrated — is refused, and nothing runs. `rebuild` adds the field again as declared, copies its values with `set`, drops the old field and renames the new one; a field it rebuilds is not required. `db.describe()` is the database's schema as the engine describes a server's (`GET /_schema`), and `db.describe('fenecql')` the statements that make it. `db.checkSchema(description, 'plan')` says what an open would do and writes nothing, the pending migrations run in a block put back. The check costs an open of 10 collections 0.09 ms in Node (0.11 ms without, 0.20 with), and the module 7.5 KB brotli (23 KB raw): the comparison and its messages, and FenecQL read into a schema. `make wasm SCHEMA=0` leaves it out. Where the check runs, and the description A database in the page (`Fenec.open`, `restore`, `openFile`) is the code's, and the check applies. A synced replica's schema is its server's: `sync({ schema })` compares the code with the server's (`POST /_schema/plan?mode=follow`), refuses what the code declares and the server lacks, and applies nothing; the replica takes its collections as the server declares them, as it always did. `connect(url, { schema })` compares the same way; with `migrate: true` and the server's token it runs the migrations and the additions on the server (`POST /_schema/apply`), as a deploy step runs drizzle-kit's `migrate`. Every SDK's declarations compile to one description, versioned JSON: `{"format": 1, "collections": [...], "migrations": [...]}`, a field `{"name", "type", "required", "collate", "nocase", "index", "paths"}` and an index `{"kind": "hnsw", "metric", "m", ...}` holding what the declaration says, the engine's defaults for the rest — or the same collections as FenecQL text, `{"format": 1, "fenecql": "create collection ..."}`, which the module reads. `integrations/schema-golden.json` holds declarations and the description each compiles to, FenecQL texts, and plans with the statements and refusals the engine makes of them, which the Rust tests run and every SDK will be held to. Coming from Drizzle, and which way to choose One to one: a table function with columns and an array of indexes, column builders without a name, `.notNull()`, `.unique()`, `.array()`, `$type`, `index` and `uniqueIndex` with `.on`, `.using` and `.with`, pgvector's `vector`, `halfvec` and `sparsevec` with their operator classes, `$inferSelect`, `$inferInsert`, `getColumns` and `defineRelations` with `r.many` and `r.one`. fenecdb's own: `fenecTable` (also `collection`), `bm25` and `inverted` methods, `.ttl()`, `.collate()`, `t.meta.path(...)`, and `quant`. Not there: `.default()` (FenecQL has no defaults), `.primaryKey()` (every document has its `id`), `.references()` (no foreign keys; a relation is `defineRelations`), an index on two columns, and index names, which are checked to be distinct and kept nowhere. Drizzle generates SQL migrations from a diff; here the engine applies what only adds by itself, and only what it refuses needs a migration, written beside the schema. Declare the schema in code for an app that makes its own database: a page's state, an app's file, a service that owns its server. Generate the types from the database ([TypeScript](#typescript)) for an app over a database another owns, a server's whose schema changes elsewhere. To move from the second to the first, `fenec types --schema data.fenec > schema.ts` writes the tables the file holds, every option written, and `fenec types --fenecql` the same schema as FenecQL; an open with either finds no difference. ## Collation data Text ordered with `collate und` or `collate tr` needs each script's sorting data. The module carries Latin and fetches the rest from `collate/` beside it the first time a page needs them. ```ts import { Fenec } from './fenec.js'; const db = await Fenec.open('./fenec.wasm'); // collate/ beside it db.run('create collection people (name text collate und @sorted)'); await db.from('people').insert([{ name: 'Ζωή' }, { name: '山田' }]); // fetches greek, han const rows = await db.query('get people order name'); // the same, raw await db.collation('cyrillic', 'arabic'); // ahead of the first statement that needs them await db.collation('all'); // every script's: 145 KB, 67 KB gzip, 61 KB brotli ``` - **`db.query()` and the builder** fetch what is missing and run the statement again. - **`db.run()`** cannot wait, so it throws a `FenecError` naming the chunks to load. How collation data is loaded `collate und` and `collate tr` need the weights of the scripts they compare ([Collation](https://fenecdb.com/docs/fenecql-schema#collation)). The module carries the Latin ones and is handed the others as a page first needs them, a chunk a group of scripts, from `collate/` beside the module — or from where `Fenec.open(src, { collation })` says: a URL, or a function handed the chunk's name that returns its bytes. `make wasm` writes the chunks next to `fenec.wasm`. A statement that would compare text of a script whose chunk the module has not got is refused rather than answered in another order: `db.run()`, which is synchronous, throws a `FenecError` whose `collation` names the chunks, and `db.query()` and the builder fetch them and run the statement again. A write is refused before it changes anything, so running it again is safe; of several statements in one text only the first is run again by itself. `restore` and `db.loadAsync()` fetch what an image's collated text needs — its `@sorted` indexes would be out of order without it — and the sync layer fetches what its seed, the changes it is sent and its optimistic writes need (`sync({ collation })` says where from). A chunk of another version's tables is refused: its ranks would mean something else. ## A smaller module A page that does not use every feature can load a module built without the rest. The release ships `fenec.wasm` alone, with every feature; another set is a build option. ```bash make wasm # every feature: 273 KB brotli make wasm FEATURES="text sorted" # those two alone, and the schema check make wasm FEATURES=indexes # the indexes, none of the rest make wasm FEATURES=language SCHEMA=0 # no index, nor the schema check: 202 KB make wasm FEATURES=none SCHEMA=0 # no feature at all: 179 KB ``` | Feature | What needs it | | `vector` | nothing but speed: without the graph `near` over a vector field, and `fuse`, measure every vector, as `exact` does, with the same rows and scores | | `text` | `match`, and the `rerank` after it, which reads the vectors themselves: BM25 needs the index's statistics, so without it `match` is refused | | `sparse` | nothing but speed: without it `near` over a `sparse` field scores every document, as `exact` does | | `sorted` | nothing but speed: without it the scan answers a `@sorted` field's comparisons and orders, with the same rows | | `composite` | nothing but speed: without it the scan answers an index over several fields, `@sorted(owner, created desc)`, and a `@unique(org, slug)` is asked by reading the rows; it needs `sorted` | | `geo` | `distance()`, `within()`, `near` over a `geo` field, and its `@geo` index (with `sorted`; without it the scan answers): without it a point is read, written and checked, and the rest is refused | | `nocase` | a text compared by its case fold: without it every statement over a collection declaring one is refused, since its text compared as written would be a wrong answer | | `collate` | `collate und` and `collate tr`, ICU's order: without it a statement over a collection declaring one is refused, as is `order ... collate` | | `facets` | `facet`, its value counts, ranges and disjunctive facets | | `highlight` | `highlight()` and `snippet()`, which mark what `match` found and so need `text` too | | `analytics` | `approx_count_distinct` and the `hll_*` sketches, and `bucket()`; every other aggregate — `count`, `sum`, `avg`, `min`, `max`, `count(distinct ...)`, `first` and `last` — and `having` are in every module | What a `near` costs in a module built without `vector`, `limit 10`, the median of 50 queries in Node (`make wasm-exact-speed`): | Rows × dimensions | `fenec.wasm`, through the graph | Built without `vector` | | 1 000 × 128 | 0.20 ms | 0.38 ms | | 10 000 × 128 | 0.22 ms | 2.4 ms | | 50 000 × 128 | 0.34 ms | 11.7 ms | | 1 000 × 384 | 0.47 ms | 0.90 ms | | 10 000 × 384 | 0.51 ms | 6.2 ms | | 50 000 × 384 | 0.75 ms | 29.4 ms | Which features a smaller module can leave out The module carries five indexes and six more parts of the language, and a page that uses fewer can load one built without the rest: `FEATURES` names what it keeps, `indexes` and `language` each group. The release and `@fenecdb/web` carry `fenec.wasm` alone; another set is a `make wasm` away. What each feature weighs is in Benchmarks. The builds without the indexes and without anything are also tests: CI builds them (`make wasm-lite`, `make wasm-min`) and opens the full module's files in them. Everything else is in every build: documents, filters, `order`, `count`, `sum`, `avg`, `min` and `max` by `group`, `lookup`, `in (get ...)`, json fields and their paths, `@ttl`, the change feed, persistence and the sync layer. The file does not change with the build. A collection that declares an index the module lacks is made and loaded all the same, and its documents are read and written. A `near` over it measures every vector, a sparse one scores every document; a `match`, or a `create index` of the kind, throws a `FenecError` naming the feature. A file declaring a `nocase` or collated field loads too; a statement over that collection throws, the feature named, and the file's other collections answer. A module that has the feature builds what it needs when it loads what the other wrote, so a page can hand a store to the other module and take it back. ## The query builder The builder writes FenecQL for you, with every value passed as a parameter. Each call returns a new query, so a base query can be shared and extended with optional filters. ```ts const rows = await db.from('articles') .select('title', 'year') .where('year', '>=', 2024) .where('tags', 'has', 'rust') .near('embed', queryVector, { ef: 128 }) .limit(10) .rows(); ``` - **Each `where` is `and`ed** with the ones before it. - **Queries are immutable,** so `if (year) q = q.where(...)` branches safely. - **No npm, no build step,** and no second schema definition. Why a builder instead of strings or an ORM FenecQL reads well, but on the application side the job is still building a string — and every interface with conditional filters ends up here: ```ts let q = 'get articles where 1=1'; if (year) q += ` and year >= ${year}`; // quote escaping and $n by hand ``` What usually drives people to an ORM is not the ORM but two things: dynamic queries and type safety. The builder gives both, without npm, a build step, or a second schema definition. Every `where` is `and`ed with the previous one, and the query object is **immutable** — so a body can be shared and branched: ```ts let q = db.from('articles').select('title'); if (year) q = q.where('year', '>=', year); if (tag) q = q.where('tags', 'has', tag); const rows = await q.limit(20).rows(); ``` ## Filters A filter can be written three ways, which all build the same query. The table lists everything else a query can carry. ```ts const articles = db.from('articles'); articles.where('year', '>=', 2024); // field, operator, value articles.where('category', 'book'); // two arguments means equality articles.where({ year: { gte: 2024 }, tags: { has: 'rust' } }); articles.where('meta.lang', '=', 'tr'); // a path into a json field ``` A path into a [`json` field](https://fenecdb.com/docs/fenecql-schema#json) goes wherever a field does — `where`, `select`, `order`, and the keys of `update`, which sets one key inside the field — and a plain object is a `json` field's value: `insert({ meta: { lang: 'tr' } })`. `fenec types` writes a `json` field as `Json`, and the builder's types take `'meta.lang'` as a path of it. | **Operators** | `=` `!=` `<` `<=` `>` `>=` `~` `has` `in` | | **Word forms** | `eq` `ne` `lt` `lte` `gt` `gte` `like` `has` `in` | | **Null** | `{ summary: null }` becomes `is null`; `{ summary: { not: null } }` becomes `is not null` | | **Combining** | `or(...)` `not(...)` `and(...)` — `where` already `and`s | | **Escape hatch** | `raw('cosine(embed, ?) > ?', vector, 0.5)` | | **Ordering** | `order('year','desc').order('title')` — successive calls add keys; `order('name', 'asc', { collate: 'und' })` puts text in Unicode's order for every language, `{ collate: 'tr' }` in Turkish order ([Collation](https://fenecdb.com/docs/fenecql-schema#collation)) | | **Ranking** | `near('embed', v, { ef, exact })`, `match('body', text)`, `rerank('embed', v, { candidates })` after a `match`, and `match(...).near(...).fuse({ k, candidates })` to rank by both ([Hybrid search](https://fenecdb.com/docs/fenecql-search#hybrid)) | | **Relations** | `lookup('reviews', { on: 'product_id', limit: 3 })` — children per row, chainable | | **Endpoints** | `rows()` `first()` `count()` `run()`, and `explain()`: the path the query took, one line a step ([Explain](https://fenecdb.com/docs/fenecql-reading#explain)) | | **Writes** | `insert(doc|docs)` `update(object)` `delete()`, each resolving to the rows it changed; `insert(doc, { ifAbsent: true })` passes over a held id or `@unique` value (`put … if absent`); `order(...)` and `limit(n)` before an `update` or a `delete` pick its rows, and `{ returning: true }` resolves to them — a job queue's claim ([Job queues](https://fenecdb.com/docs/queues)) | | **Values worked out** | `update({ n: inc(1) })` renders `n: coalesce(n, 0) + $1`, and `expr('price * ?', 1.2)` any expression over the row — see [Writes that read the row](https://fenecdb.com/docs/fenecql-writing#writes) and [Instead of Redis](https://fenecdb.com/docs/redis) | lookup, or, not and raw Use whichever spelling reads better at the call site. `lookup` hangs another collection's matching documents off each row. Everything in its options object binds to that collection, so `limit` there counts children per parent — twenty products with three reviews each, in one query: ```ts const rows = await db.from('products') .where('price', '>=', 10000) .limit(20) .lookup('reviews', { on: 'product_id', where: { stars: { gte: 4 } }, order: [['created', 'desc']], limit: 3, }) .rows(); // [{ id: 1, name: 'Kahve', price: 12000, reviews: [ {...}, {...} ] }, ...] ``` Calling it again chains rather than replaces: the second call binds to the collection the first one named, the way position scopes it in FenecQL. ```ts const rows = await db.from('shops') .limit(20) .lookup('orders', { on: 'shop_id', limit: 3 }) .lookup('lines', { on: 'order_id', limit: 5 }) .rows(); // [{ name: 'Merkez', orders: [{ code: 'A', lines: [ {...} ] }, ...] }, ...] ``` A level's `select` may aggregate, as the query's own does: each parent's children folded — one row a parent, or with `group` one a group, ordered and paged a parent at a time. Ungrouped, `order` and `limit` pick the children folded. A query whose list aggregates hangs a lookup off its groups, `parentKey` naming a column; and `bucket(field, interval, zone)` counts by a zone's clock, the zone a parameter. ```ts import { bucket } from './fenec.js'; // Each product's latest 50 reviews, counted and averaged. const page = await db.from('products').limit(20) .lookup('reviews', { on: 'product_id', order: [['created', 'desc']], limit: 50, select: ['count(*)', 'avg(stars)'] }) .rows(); // The ten most reviewed, with their names. const most = await db.from('reviews') .select('product_id', 'count(*)').group('product_id').order('count(*)', 'desc').limit(10) .lookup('products', { on: 'id', parentKey: 'product_id', select: ['name'] }) .rows(); // Reviews a day, by the page's own clock. const days = await db.from('reviews') .select(bucket('created', '1d', Intl.DateTimeFormat().resolvedOptions().timeZone).as('day'), 'count(*)') .group('day').rows(); ``` The row type follows the nesting, so `rows[0].orders[0].lines[0]` is typed without a cast; a level that folds is typed as the children it folds, its columns not narrowed to. `required` keeps meaning what it means at its own level: on `lines` it drops the orders that have none, and the shops stay unless `orders` asks for it too. The child's field names cannot be reached from a builder that carries one collection's types, so in TypeScript they default to a loose row; naming them tightens it: `.lookup<'reviews', FenecSchema['reviews']>('reviews', {...})`. `or` and `not` are separate functions because the `where` chain is an `and`. A nested group is written explicitly and the parenthesising is left to the builder: ```ts import { or } from './fenec.js'; db.from('articles') .where('year', '>=', 2024) .where(or({ tags: { has: 'rust' } }, { title: { like: 'rust' } })) // get articles where year >= $1 and (tags has $2 or title ~ $3) ``` `raw` covers what the builder cannot express, which in practice today means function calls. Its `?` placeholders bind to parameters in order, so the escape hatch does not become string concatenation either. ## A search page A results list with the words marked, and a sidebar counting each brand and color over every match, from one query: `highlight()` and `facet()`. ```ts import type { Mark } from '@fenecdb/web'; /** The text with each mark in a , as text nodes: nothing in it is HTML. */ function marked(text: string, marks: Mark[]): DocumentFragment { const out = document.createDocumentFragment(); let at = 0; for (const [start, end] of marks) { const m = document.createElement('mark'); m.textContent = text.slice(start, end); out.append(text.slice(at, start), m); at = end; } out.append(text.slice(at)); return out; } async function search(words: string, brand: string | null = null) { let q = db.from('products') .select('name', 'price') .highlight('name') .match('name', words, { fuzzy: true, prefix: true }) .facet('brand', { top: 10 }) .facet('color') .limit(20); if (brand) q = q.where('brand', brand); const rows = await q.rows(); results.replaceChildren(...rows.map((r) => { const li = document.createElement('li'); li.append(marked(r.name ?? '', r['highlight(name)'] ?? []), ` ${r.price}`); return li; })); sidebar.replaceChildren(...rows.facets.brand.map((f) => { const b = document.createElement('button'); b.textContent = `${f.value ?? 'no brand'} (${f.count})`; b.onclick = () => search(words, f.value); return b; })); } ``` - **It finds what is being typed**: `fuzzy` reads each word within a typo or two, `prefix` the last word as the start of one — `databse` and `datab` both find `database`, and the marks fall on it (Typos and words begun). - **The offsets are a JavaScript string's own** (UTF-16), so `text.slice(start, end)` is the marked word, past an emoji or an accent. - **`rows.facets` counts every match**, not only the twenty on the page; typed by the fields: `f.value` is a `string | null` here. - **The same query live**: `useLiveQuery(q)` hands rows with their `facets`, as `db.live` does. Highlights, snippets and facets in the builder `.highlight(field, { pre, post })` answers the marked text instead of the offsets, and `.snippet(field, words, { ellipsis, pre, post })` a window of that many words around the marks — see Highlights and snippets. Text with tags is not escaped, which is why the page above builds its nodes from the offsets. `.facet(field, { top })` takes a field or a path into a `json` one, and `run()` hands the counts as its `facets` too — see [Facets](https://fenecdb.com/docs/fenecql-search#facets). A facet beside `near` is refused: `near` ranks every row rather than selecting some, so the counts would be the filter's alone. `{ disjunctive: true }` counts a facet without the filter's own conditions on its field, and `{ ranges: [0, 2500, 5000] }` counts a number by range ([ Counted without its own filter](https://fenecdb.com/docs/fenecql-search#disjunctive-facets), [Ranges](https://fenecdb.com/docs/fenecql-search#range-facets)). A query built in steps keeps the type it was declared with, so a facet asked after the declaration is typed as any facet: `rows.facets` is there, each field's counts `FacetCount[]`, and maybe absent. To keep each field's own type, ask the facets in the declaration and add the steps that keep the row's type after it: ```ts import { connect } from '@fenecdb/web/client'; const db = connect('https://db.example.com'); const chosen: string | undefined = 'acme'; let q = db.from('products').select('name').facet('brand', { disjunctive: true }); if (chosen) q = q.where('brand', chosen); const rows = await q.limit(24).rows(); rows.facets.brand.map((f) => f.count); // typed ``` ## Seeing what it produces To see the text and parameters a chain builds, for logging or a test, call `toFenecQL()`. It returns exactly what will run, and runs nothing; a query needs no call to it before it runs. ```ts db.from('articles').where('year', '>=', 2024).limit(10).toFenecQL() // ['get articles where year >= $1 limit $2', [2024, 10]] ``` - **Values are always parameters,** never pasted into the text. - **Collection and field names** are checked against FenecQL's identifier rule; anything else throws. How values and names stay out of the query text Nothing is hidden. Every leaf value is bound to a parameter and text is never embedded into the query, which leaves a single injection boundary: **names**. Collection and field names cannot be parameterised, so they are validated against FenecQL's identifier rule and anything that does not fit errors out. ```ts db.from('articles').where('title', '"; del articles; --') // get articles where title = $1 ← a value, not text ``` A parameter that is a vector — a `Float32Array`, or any array of finite numbers, which the module reads as a vector either way — goes into the module as its `f32`s beside the JSON rather than as text: 200 768-dim vectors are put in 4.4 ms, against 21.9 written out as digits and read back. The vector stored is the same, `-0` as `0` as JSON writes it; over HTTP a parameter is JSON. Where such a parameter is given a `json` field, or compared with a path into one, the module names it before running anything and `run` sends it again as JSON, so a plain array keeps its numbers as written and a `Float32Array` goes as the numbers it holds. ## Independent of the transport The same query code runs against the module in the page, a `fenec-server` server or an HTTP endpoint: build it with `from()`, then `bind()` it to where it should run. ```ts import { from, connect } from './fenec.js'; const q = from('articles').where('year', '>=', 2024); await q.bind(db).rows(); // wasm, local await q.bind(connect('http://localhost:8080')).rows(); // remote server q.toFenecQL(); // or just the text ``` What connect() sends `connect()` returns the same builder; the FenecQL it produces goes to `POST /query` as is. See [HTTP endpoint](https://fenecdb.com/docs/http). ## TypeScript `fenec.d.ts` beside the module types the client, and `@fenecdb/web` ships it. A schema declared in code types it ([Schema in code](#schema)); for a database another owns, generate the schema type from the database file, and queries are checked against its real schema: unknown fields, wrong value types and unselected fields are compile errors. ```bash fenec types data.fenec > web/fenec-schema.d.ts # or: make types FILE=data.fenec fenec types schema.fenecql > web/fenec-schema.d.ts # from a schema file, with no database fenec types --lang python data.fenec # a row in Python, Go, C#, Swift, Kotlin or Dart ``` ```ts import { Fenec } from './fenec.js'; import type { FenecSchema } from './fenec-schema.js'; const db = await Fenec.open('./fenec.wasm'); const rows = await db.from('articles').select('title', 'year').rows(); // rows: { title: string | null; year: number | null }[] await db.from('articles').insert({ title: 'Dunes', year: 2021, published: new Date() }); const products = await db.from('products') .lookup<'reviews', FenecSchema['reviews']>('reviews', { on: 'product_id', limit: 3 }) .rows(); // products[0].reviews[0].stars: number | null ``` How types map, and what is generated The schema is not written a second time — it is generated from the live file, so the two cannot drift apart. A schema declared in code is written a second time on purpose, and the check at every open is what keeps the two one. | Schema | TypeScript | | `bool` `int` `float` `text` | `boolean` `number` `number` `string` | | `timestamp` | `Timestamp` — ISO text on read; a `Date` or number works on write | | `vector` / `bytes` | `Vector` / `Bytes` — `number[]` on read, each component the shortest decimal that reads back as its `f32` (`0.1` as written, not `0.10000000149011612`); `Float32Array` also accepted on write | | `[type]` | `type[]` | | a field that is not required | `| null` — an unwritten field reads as `null` | What the types catch: a collection or field that does not exist, a wrong value type, access to an unselected field, `near` applied to a non-vector field, and a type mismatch inside `insert`. Objects inside `or`, `and` and `not` are checked against the query's collection rather than the first argument. A query using `near` or `match` gains `_score: number`. A standalone `from()` cannot know the collection name, since the schema lives in the call rather than in `db`. In TypeScript it binds through `TypedFrom`; at runtime it is the same function and the only difference is name completion. ```ts import { from as fenecFrom, type TypedFrom } from './fenec.js'; import type { FenecSchema } from './fenec-schema.js'; const from: TypedFrom = fenecFrom; from('articles').where('year', '>=', 2024); // completes like db.from ``` `fenec.d.ts` is hand-written — it is the client's own surface — and `fenec-schema.d.ts` is generated. There is no link between them: the `Timestamp`, `Vector` and `Bytes` brands are structural, so the generated file stands alone and imports nothing. ## What the builder will not do The builder adds nothing FenecQL lacks, and refuses a few things that are easy to get wrong. - **No aggregate FenecQL lacks.** Aggregates go in `select` spelled as FenecQL spells them — `.select('status', 'count(*)', 'sum(total)').group('status')` — and each row answers under those names; a median or a distinct count is not there to compile to. - **An `update` or `delete` without a filter is refused.** Covering a whole collection by accident is far too easy and impossible to undo; when it is deliberate you write `delete({ all: true })`. PostgREST decides the same. - **`near`, `order`, `limit` and `lookup` error out on write statements.** FenecQL does not support them on `set` or `del`, and ignoring them silently would breed the illusion that `limit(1)` deletes a single row, or that a required `lookup` spares the parents with no children. - **No JOIN, no `returning`.** The builder adds no capability on top of FenecQL — it only builds it safely. `lookup` is the exception FenecQL itself makes, and `.lookup(name, opts)` builds it like any other clause; a query given as an `in`'s value is FenecQL's `in (get …)`, its parameters bound with the outer one's. How the builder is tested The Python, Go and .NET clients have the same builder, and `integrations/builder-golden.json` holds all four to one text: the chains there and what this builder makes of each, which every language's tests run ([Your language](https://fenecdb.com/docs/languages#builder)). The tests run with `node --test web/fenec.test.js` and are part of `make test`. When `web/fenec.wasm` is present, an end-to-end test that actually parses the generated text runs too. --- Source: https://fenecdb.com/docs/mobile # Mobile and native apps Keep the database in a file on the device: no server, no network, the same engine and file format as the server and the browser. An iOS, macOS, Android or Flutter app opens a fenecdb file, writes to it and asks it questions in FenecQL or through the query builder, with `near`, `match` and `lookup` as anywhere else. A live query hands the UI its rows again after every write to what it reads. |   | Swift | Kotlin and Java | Dart and Flutter | | install | SwiftPM: `github.com/fenecdb/fenecdb`, product `FenecDB` | `com.fenecdb:fenecdb-android` (the AAR), `com.fenecdb:fenecdb` (the JVM) | `fenecdb_flutter`, or `fenecdb` in Dart alone | | platforms | iOS 15, macOS 12 | Android 5 (API 21): arm64-v8a, armeabi-v7a, x86_64; the JVM | iOS, Android, macOS | | calls | `async`, off the caller's thread | `suspend` on `Dispatchers.IO`; `...Blocking` for Java | `Future`s, on an isolate of the database's | | live queries | `LiveQuery` (`ObservableObject`), `LiveRows` (`@Observable`), an `AsyncThrowingStream` | a `Flow`: Compose's `collectAsState` | a `Stream`: `StreamBuilder` | | rows | `Row`, or any `Decodable` | `Row`, a `Map` with typed getters | `Map` | How the bindings share one library Each binding calls the same native library, `crates/fenec-ffi`: the engine built for the device, behind a C ABI of a few functions -- open, query, the change notice, sync, flush, checkpoint, close. A statement goes in as text with its parameters as JSON, and its answer comes back as the JSON the browser module answers with, from the same code (`fenec-abi`), so a page and an app handed the same text get the same bytes. A vector goes in as its `f32` bytes beside the JSON, never as digits. The same file can be a replica a server keeps in step ([Sync with a server](#sync)), and an app can ask a server with no file at all ([Without a file](#connect)). ## A todo list The file in the app's own directory, a collection made once, a list on screen that is a live query, and writes from the buttons. The list redraws itself: nothing calls it after a write. ```swift import FenecDB import SwiftUI struct Todo: Codable, Sendable, FenecValue { var id: Int? var title: String var done: Bool } @main struct TodosApp: App { @State var db: Fenec? var body: some Scene { WindowGroup { if let db { TodoList(db: db) } else { ProgressView().task { db = try? await open() } } } } func open() async throws -> Fenec { let dir = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let db = try await Fenec.open(dir.appendingPathComponent("todos.fenec")) try await db.execute("create collection if not exists todos (title text, done bool @hash)") return db } } struct TodoList: View { let db: Fenec @StateObject var open: LiveQuery init(db: Fenec) { self.db = db _open = StateObject(wrappedValue: LiveQuery(db, try! db.from("todos").where("done", false), as: Todo.self)) } var body: some View { List(open.rows, id: \.id) { todo in Button(todo.title) { Task { try await db.from("todos").where("id", todo.id!).update(["done": true] as Value) } } } .toolbar { Button("Add") { Task { try await db.from("todos").insert(Todo(title: "milk", done: false)) } } } } } ``` On iOS 17 and macOS 14, `LiveRows` is the same as an `@Observable`, held in `@State`. ```kotlin import com.fenecdb.* class TodosModel(app: Application) : AndroidViewModel(app) { val db = Fenec.open(File(app.filesDir, "todos.fenec").path) // in a background start-up, or openAsync init { db.executeBlocking("create collection if not exists todos (title text, done bool @hash)") } val open: Flow> = db.live(db.from("todos").where("done", false)) fun add(title: String) = viewModelScope.launch { db.from("todos").insert(mapOf("title" to title, "done" to false)) } fun finish(id: Long) = viewModelScope.launch { db.from("todos").where("id", id).update(mapOf("done" to true)) } override fun onCleared() = db.close() } @Composable fun Todos(model: TodosModel) { val open by model.open.collectAsState(emptyList()) LazyColumn { items(open) { todo -> Text(todo.string("title") ?: "", Modifier.clickable { model.finish(todo.long("id")!!) }) } } Button(onClick = { model.add("milk") }) { Text("Add") } } ``` A live query is a conflated `Flow`: a collector that falls behind sees the latest rows. ```dart import 'package:fenecdb_flutter/fenecdb_flutter.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final dir = await getApplicationSupportDirectory(); final db = await Fenec.open('${dir.path}/todos.fenec'); await db.execute('create collection if not exists todos (title text, done bool @hash)'); runApp(MaterialApp(home: Todos(db))); } class Todos extends StatelessWidget { final Fenec db; const Todos(this.db, {super.key}); @override Widget build(BuildContext context) => Scaffold( body: StreamBuilder( stream: db.live(db.from('todos').where('done', false)), builder: (context, snap) => ListView(children: [ for (final todo in snap.data ?? const >[]) ListTile( title: Text(todo['title'] as String), onTap: () => db.from('todos').where('id', todo['id']).update({'done': true}), ), ]), ), floatingActionButton: FloatingActionButton( onPressed: () => db.from('todos').insert({'title': 'milk', 'done': false}), child: const Icon(Icons.add), ), ); } ``` The plugin's `example/` is this app, built for Android and the iOS simulator on every change. A fuller app, Notes -- search with `match` and `fuse`, filters, a schema file, an add form -- is in the repository for each: [SwiftUI](https://github.com/fenecdb/fenecdb/tree/main/examples/swift) (and a CLI), [Jetpack Compose](https://github.com/fenecdb/fenecdb/tree/main/examples/kotlin-android) (and a JVM CLI) and [Flutter](https://github.com/fenecdb/fenecdb/tree/main/examples/flutter), each built on every change and its headless part run. ## The builder The same query builder as JavaScript, Python, Go and .NET: `from`, `select`, `where` with every operator, `near`, `match`, `fuse`, `rerank`, `highlight`, `snippet`, `facet`, `lookup`, `order`, `limit`, and the writes, each value a parameter and each name checked. All seven make the same text of the same chain, to the byte, held to one golden file; each language's spelling is on [Your language](https://fenecdb.com/docs/languages#builder). A builder query also knows the collections it reads, which is what a live query runs again for. To see the text and parameters a chain builds, for logging or a test, `toFenecQL()` returns them and runs nothing. ```swift let near = try await db.from("notes") .select("title") .where("lang", "en") .near("embed", embedding, ef: 64) // a [Float] .limit(5) .rows(as: Note.self) ``` ## The schema in code An app that makes its database declares it once, as `create collection` and `create index` statements -- a `schema.fenecql` in the app's resources -- and hands it to `schema` after each open. The engine compares the file with it: what only adds (a collection, a field, an index) is made, the migrations not yet recorded run first, in order, and are kept in `_migrations`, all one block; a field the text lacks, a type or an index changed, is refused with what resolves it, and nothing is written. It is the check the browser's `Fenec.open({ schema })` and the server's `/_schema` run ([Schema in code](https://fenecdb.com/docs/javascript#schema)). ```swift let plan = try await db.schema(schemaText, migrations: [ "alter collection notes rename field title to name", ]) ``` ```kotlin val plan = db.schema(schemaText, migrations = listOf( "alter collection notes rename field title to name", )) ``` ```dart final plan = await db.schema(schemaText, migrations: [ 'alter collection notes rename field title to name', ]); ``` `fenec types --lang swift|kotlin|dart schema.fenecql` writes a row of each collection as the language reads it: a `Codable` struct, a data class with `from(row)`, a class with `fromJson`. ## Live queries A live query hands over its rows at once, and again after a write to a collection it reads. The writes of a burst -- several at once, a loop, a text of several statements -- run it once, a frame after the last of them lands. A write to another collection runs nothing, and a text that fails is put back whole and runs nothing either. How a live query re-runs The engine keeps a ring of the writes it landed, and `fenec_changes(since)` names the collections written since a cursor -- or answers "everything" when the cursor fell behind the ring or a collection written since was dropped. A write asks for a look; the looks asked for within a frame (16 ms) are one, taken once no write through the database is under way, and each query that reads a collection named runs again from scratch -- well under a millisecond over a local file, where keeping track of which rows changed would cost more than the query. Knowing costs a write nothing: the ring is asked once after a burst, and only by a database holding a live query. It is how the browser's `Fenec.live` works, with a frame in place of a microtask: an app's writes run on other threads and come back one at a time. A builder query reads its own collection and each `lookup`'s; a FenecQL text names its collections, or runs after every write. ## Sync with a server The todo list kept in step with a server, every device seeing every change: open the file as a replica of the rows you want. That is the one line that changes -- the list is still a live query of the file, and a tap still writes to it. ```swift let db = try await Fenec.sync(url: "https://api.example.com", token: jwt, shapes: [Shape("todos", key: "key")], path: dir.appendingPathComponent("todos.fenec").path) ``` ```kotlin val db = Fenec.sync(url = "https://api.example.com", token = jwt, shapes = listOf(Shape("todos", key = "key")), path = File(app.filesDir, "todos.fenec").path) ``` ```dart final db = await Fenec.openSynced(url: 'https://api.example.com', token: jwt, shapes: [Shape('todos', key: 'key')], path: '${dir.path}/todos.fenec'); ``` The collection is made on the server, with a key the app's inserts are matched by -- `create collection todos (key text @unique, title text, done bool @hash)` -- and comes to the replica from there, so the `create collection` line goes. Dart's is `openSynced`: `sync()` is the fsync, and Dart has no static and instance member of one name. - **Reads are the file's.** A query, a live query and the builder read the replica: no network, and the app works offline. - **A shape is a set.** The rows of one collection a filter on the server picks, `Shape("todos", where: ["owner": me])`, in the JS sync layer's form; one shape a collection. Order and limits are a query's, not a shape's. - **A write shows at once.** It is applied to the replica as it is made, and sent. An insert gets a temporary id and a key, and the server's copy takes its place when it arrives. - **A refused write is put back.** When the server refuses it -- a `409` for a unique value held, a `403` from a token's rules, a `507` from a tenant's quota of bytes or rows (`quota` names it) -- the replica is put back as it was and the app is told: `db.replica.refusals`. - **Offline, writes wait.** A write made while the server cannot be reached waits in the file, and goes once it can, in order, once each. An app closed meanwhile sends it when it opens again. - **It catches up from where it stopped**, after a dropped connection, a server restart or a week in a drawer -- or is sent the whole shape again when the server's ring of changes no longer reaches back that far. |   | Swift | Kotlin | Dart | | open | `Fenec.sync(url:token:shapes:path:)` | `Fenec.sync(url, token, shapes, path)` | `Fenec.openSynced(url:, token:, shapes:, path:)` | | status | `replica.status`, `statuses()` (an `AsyncStream`) | `replica.status` (a `StateFlow`) | `replica.status`, `statuses` (a `Stream`) | | first seed, writes answered | `ready()`, `pushed()` | | refused writes | `refusals()` | `refusals` (a `SharedFlow`) | `refusals` | | a new token | `setToken(jwt)`, or a `tokenProvider` asked on a `401` | | the network | `setOnline(bool)`, `resume()` | | the server itself | `replica.remote` ([below](#connect)) | The status says `online`, `offline` or `catchingUp` (connecting, seeding, or sending the writes left from before), how many writes the server has not answered, and the last error. How the sync core and the bindings divide the work What to ask, what an answer means and what is written are one state machine in Rust (`fenec_abi::sync`), with no network of its own: a binding feeds it what happened -- a request's answer, a stream's bytes, a timer, the network, a new token -- and does what it hands back: make this request, open this stream, wait this long. So the logic exists once, with one suite of tests driving it through scripted server event sequences, and each binding's loop is a page over its platform's HTTP client. A write to a shape's collection through `run`, the builder or `execute` goes to it on its own: applied to the replica, what puts it back kept, and the write queued with an idempotency key -- all one block in the file. Writes go one at a time, in order, each under its key, so a write whose answer was lost is sent again and answered as the first time, not made again. A server's answer of `0` (no answer), `408`, `429` or `5xx` is tried again with backoff, 250 ms doubling to 15 s, or after the wait a tenant's quota (to the millisecond, `retry_after_ms`), a move or a failover (`retry_after`) names where that is longer; a `401` asks for a token; a `507` naming a tenant's quota, and any other `4xx`, is a refusal, and the writes behind it go on ([why](https://fenecdb.com/docs/sync#refusals-retries-and-offline)). The status's error names the quota too: a stream refused by the quota of streams leaves the replica `offline`, as the network does. A change applies in one block with the shape's cursor, so a crash leaves the two agreeing. A change saying the server's schema changed, or rows with a field the replica has not got, has the collection made again from `GET /collections` -- its rows kept in the fields that are left -- and the whole shape sent again. The file holds three collections of the sync's own beside the replica's: `_sync_shapes` (each shape's cursor), `_sync_queue` (the writes not yet answered) and `_sync_temps` (rows an insert made under a temporary id, until the server's copy). A collection with no shape is the app's own, written and read as in a file of its own; a synced collection's schema is the server's, and `create index` or `drop` over it is refused. A change of one row costs the replica 18.4 µs against 7.1 for the same put straight into the engine (the cursor is written in the same block), rows in changes of 100 cost 3.8 µs either way, and a seed of 10 000 rows 72 ms (`make sync-bench`). The browser's `sync` ([Sync](https://fenecdb.com/docs/sync)) is written apart -- this core in the browser module was 21 KB brotli more -- and the two are held to one script: `integrations/sync-scenarios.json`, 71 scenarios of shapes, writes, answers and server events, each step's replica, queue and requests stated once and run against both. What is left between them is the platforms': - A write returns once it is applied and kept: `await` on a button would hang offline. A refusal comes on `refusals`, and `pushed()` waits for the answers. In the browser the write's promise settles with the server's answer, which a page need not await; `onRefused` and `pushed()` are there too. - A text of several writes is one write, sent as one `/batch`; the browser's builder writes one statement at a time, and `batch()` makes several one write the same way. - There is one replica a file, held by one process, so no leader is elected among tabs; the file is the persistence, where a page stores its replica in IndexedDB (`persist`) or an `openFile`. - Every collation is in the library; the browser module fetches a script's chunk the first time a row needs it. ### TLS on devices iOS (App Transport Security) and Android refuse plain `http://` by default, and so should you: put TLS in front of `fenec-server`. Caddy does it in three lines, certificate included: ```text api.example.com { reverse_proxy 127.0.0.1:8080 } ``` Each binding makes its requests with the platform's client -- `URLSession`, `HttpURLConnection`, `dart:io`'s `HttpClient` -- so the system's trust store, proxies and pinning apply. An Android app declares the `INTERNET` permission and a sandboxed macOS app the `com.apple.security.network.client` entitlement, as for any request. During development, an emulator reaches a server on the machine at plain `http://` once allowed: iOS's `NSAllowsLocalNetworking`, Android's `network_security_config` with `cleartextTrafficPermitted` for `10.0.2.2`. Why a proxy can cut the change stream The change stream is Server-Sent Events: a response that does not end. Caddy passes `text/event-stream` through as it comes; behind nginx, the server's `X-Accel-Buffering: no` does the same. A proxy that closes an idle connection sooner than the server's keepalive (a comment every 20 s) cuts the stream, which then opens again from its cursor -- harmless, a round trip each time. fenecdb's own HTTP client, the server's, speaks no TLS and is not used on a device. ### Battery and the background A replica holds one connection a shape open while the app runs, quiet but for the keepalive. iOS suspends an app soon after it leaves the screen, and Android stops one's network in Doze: the streams are cut, and nothing is lost -- a write waits in the file, a change waits on the server. As the app comes back, call `resume()` (on `scenePhase == .active`, `onStart`, `AppLifecycleState.resumed`): the streams open at once from their cursors, the waiting writes go first, and the live queries run again as the changes land. What setOnline(false) and backoff do `setOnline(false)` as the app leaves the screen closes the streams at once rather than when the system cuts them, and spares the radio the keepalive. Backoff after a failure doubles from 250 ms to 15 s, with jitter, so a server back up is not met by every device at once; `resume()` and `setOnline(true)` forget it. Nothing here runs in the background: a write made just before suspension is sent on the next resume, or by an app's own background task (iOS's `BGAppRefreshTask`, Android's WorkManager) that opens the replica and waits for `pushed()`. A replica away longer than the server's ring of changes reaches back (`--changes`, 4 096 writes by default) is sent its shapes whole on return. ## Without a file: the server alone An app that keeps nothing on the device asks the server directly: `Fenec.connect` makes every query a request, through the same builder, as `connect` does in JavaScript. ```swift let db = Fenec.connect(url: "https://api.example.com", token: jwt) let open = try await db.from("todos").where("done", false).rows() ``` Kotlin's is `Fenec.connect(url, token = jwt)`, Dart's `Fenec.connect(url, token: jwt)`; a replica's `replica.remote` is the same, for what the replica does not hold. Live queries are the file's alone. Several writes that must land together are one `batch` (`POST /batch`): the builder's `toInsert`, `toUpdate` and `toDelete` under one write lock, all of them or none. One that stops it is an error naming its place (`at`, from 0) and the HTTP `status` -- 412, code `unmet`, for a write whose `require` was not met. A retry after a timeout cannot tell whether the write ran, so a write that must not run twice goes under an idempotency key: the second answer is the first one kept (`replayed`), and the key sent with another request is refused (422). ```swift let accounts = try db.from("accounts") let done = try await db.batch([ try accounts.where("id", from).where("balance", ">=", 10).toUpdate(["balance": .inc(-10)] as Value, require: 1), try accounts.where("id", to).toUpdate(["balance": .inc(10)] as Value, require: 1), ], idempotencyKey: transferID) // done.results, done.seq, done.replayed try await db.withIdempotencyKey(orderID).from("orders").insert(order) ``` Kotlin's is `db.batch(listOf(...), idempotencyKey = id)` and `runList(text, params, idempotencyKey = id)`, Dart's `db.batch([...], idempotencyKey: id)`; all three have `withIdempotencyKey(key)`, a copy whose every write -- the builder's too -- carries the key, and `seq`, the furthest change a write left the server at, through the connection or any copy of it. `withWait` -- Swift's in seconds, Kotlin's in milliseconds, Dart's a `Duration` -- is a copy whose `set`s and `del`s the server holds while they write nothing, a worker's claim waiting there for a job ([Waiting for a job](https://fenecdb.com/docs/queues#wait)); cancelling the task or the coroutine gives it up. An app far from its tenant's home reads near itself where the tenant has a [read replica](https://fenecdb.com/docs/sharding-read-replicas#read-replicas) in the app's region: connected through that region's router with reads nearest, the builder's reads, and an `asRead()` copy's requests, are answered by the replica there, each after the furthest position an answer from home named to this connection -- its change and its history, shared by every copy -- so the app never reads older than it wrote; writes go home. ```swift let db = Fenec.connect(url: "https://ap.example.com/t/acme", token: jwt, reads: .nearest) try await db.from("orders").insert(["number": "SG-1"] as Value) // home, in eu let mine = try await db.from("orders").rows() // ap's replica, after that write let n = try await db.asRead().query("get orders count") // a text that only reads ``` Kotlin's is `Fenec.connect(url, token = jwt, reads = FenecRemote.Reads.NEAREST)`, Dart's `Fenec.connect(url, token: jwt, reads: Reads.nearest)`, and all three have `asRead()`. A write sent through `asRead()` is refused (status 403), never landed. ## Where the file lives Anywhere the app may write. On iOS the app's Application Support directory, which is backed up and never shown in the Files app; on Android `context.filesDir`, or `noBackupFilesDir` to keep it out of a backup; in Flutter `path_provider`'s `getApplicationSupportDirectory()`. Beside the file the library keeps `.lock` while it is open. - **Open a file once.** A second open of the same file, in the app or in another process -- an app extension sharing the app group's container -- is refused (`locked`): two databases over one file corrupt it. - **iOS file protection.** A file under the `complete` protection class cannot be read while the device is locked, and a page of a mapped file read then is the app's end, not an error. Keep the default class (complete until first user authentication), or open the file with `.inMemory`. - **The file compacts itself.** An update writes the row again at the file's end; a thread of the library's looks every 5 s and compacts the file beside your calls once half of it is versions updates and deletes left behind, and at least 64 MB, so a file you keep updating stays under about twice what it holds -- sooner once it reaches three quarters of the memory the device has for its pages, so its reads stay in memory. Open with `.noAutoCompact` (`NO_AUTO_COMPACT`, `noAutoCompact`) to compact only when you run `compact`. How the file is mapped and locked on a device The file is mapped, as a server maps its own: the documents stay in it and the app holds only what it derived from them, the indexes and the graph. The mapping takes address space past the file's end -- a gigabyte at least -- for the writes to grow into; where the system refuses that much, as an iOS app's address space may, the file alone is mapped and mapped again as it grows. A 32-bit Android device (armeabi-v7a) reads the file into memory. The lock is a file of its own, held with a record lock (`fcntl`), which belongs to the process: a checkpoint renames a new file over the database, which a lock on the database itself would not survive, and an `flock` belongs to the open file, which a child process started meanwhile shares until it runs its program -- a close and an open again at that moment were refused. ## Durability Every write is on disk before the call returns: written and fsynced, outside the database's lock, so reads go on meanwhile and writes arriving together share one fsync. On a Mac that fsync is about 4 ms. For a burst of writes, open the file without it and sync when they are done: |   | what it does | what it survives | | a write, by default | written and fsynced before it returns | the device losing power | | `noSync`, then `sync()` | writes wait in a buffer, then one fsync | the device losing power, once synced | | `flush()` | the buffer handed to the system, no fsync: microseconds | the app being killed | | `checkpoint()` | the file written anew, graphs and all | -- (the next open links nothing) | | `close()` | saves the graphs, syncs, lets the file go | everything | A phone ends an app in the background without a word, so an app opened with `noSync` calls `flush()` or `sync()` as it goes there -- `scenePhase` turning to `.background`, `onStop`, `AppLifecycleState.paused`. What noSync and a refused write do With `noSync` the buffer goes into the file once it holds 64 KB, written by the call that filled it after it has let go of the database, as a server's buffered writes are: a read beside texts of 1 000 rows of 128 dimensions waits 1.1 to 1.4 ms at p99, where written under the lock, the disk's waits and all, it waited 2.2 to 6.6 ms and up to 61. A write the disk refuses stops the database's writes, as on a server: every later write and sync is refused with `io` until the file is opened again, and reads go on. The vectors a database holds are linked into its graph as they are written; the open links those written since the graph was last saved. `close()` saves a graph once the file has grown three times the graph's record since its last save, so an app that opens and closes often does not grow its file by a graph each time; `checkpoint()` saves everything, and an open after it links nothing. The hash, text, ordered and sparse indexes are never in the file: an open leaves each for the first statement that reads it, which builds it first -- a `@text` index of 100 000 products took that search 141 ms. `warm()` builds them off the main thread as the app starts, each under the read lock on its own so reads go on beside it, and answers how many it built; `warm(["docs", "orders.status"])` builds those collections' and fields' alone (Kotlin `warm(listOf(...))`, Dart `warm([...])`). ## Threads One database is safe to use from every thread, task or coroutine at once: a read runs beside other reads, a write takes the database to itself, a long read — an aggregate, a scan, a text of several reads — pins what it reads and lets writes land beside it, as a server's does, and a lone `create index` or `compact` is built beside the database while reads and writes go on. Every binding makes its calls off the main thread, since any of them may wait -- for the lock, an fsync, a whole open. ## Sizes and speed | the native library, stripped | size | | macOS, Apple silicon | 1.68 MB | | macOS, Intel | 1.77 MB | | Linux arm64, with the JNI functions | 1.71 MB | | Linux x86-64, with the JNI functions | 1.88 MB | The sync with a server is 165 to 198 KB of each. iOS's and Android's are measured by CI for every change, in the run's summary. An app ships the code it links: the XCFramework's static library is the objects before the app's linker takes what it uses. | 20 000 × 128, Apple M1 | through the library | fenec-server's handler, in process | | open, checkpointed | 2.9 ms | 2.6 ms | | a put with its vector, buffered | 4.6 µs | 23.2 µs | | a put with its vector, fsynced | 4.0 ms | 4.0 ms | | `near`, `limit 10` | 97.8 µs | 151.4 µs | Method, and the library's timings against the server's `make ffi-bench` makes the file through the library and opens it both ways: the library as an app opens it, the server's handler as `fenec-server` serves it, the request built in process with no socket. The medians of 1 000 calls each. The library is handed a vector as its `f32` bytes, as the bindings hand it; as JSON a buffered put takes 23.3 µs, the server's time, most of it the vector's digits. ## The C ABI Any language with a C FFI can call the library: the header is `crates/fenec-ffi/include/fenec.h`, and `make ffi` builds the library for this machine, or `TARGET=...` for another. Every call returns a code -- 0, an error's kind, or the boundary's own: `FENEC_MISUSE`, `FENEC_LOCKED`, `FENEC_PANIC` -- and writes its answer or its error as NUL-terminated JSON, freed with `fenec_free_string`. Handles, panics and JNI A handle is a number, not a pointer: a handle used after its close finds nothing and is refused, where a pointer would reach freed memory, and a call holds its database for as long as it runs whatever closes meanwhile. A panic never crosses the boundary: each call runs under `catch_unwind` and answers `FENEC_PANIC` with its message, which is why the library is built to unwind, not to abort. The Kotlin binding reaches the same calls through JNI functions the library carries itself (its `jni` feature): each call answers one `byte[]`, its code then its JSON, and text goes in as UTF-8 bytes, since JNI's own strings write an emoji as two halves. --- Source: https://fenecdb.com/docs/http # 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](https://fenecdb.com/docs/http-reference), and tokens and policies in [HTTP security and scoped access](https://fenecdb.com/docs/http-access). ## Start it Add `--http` to `fenec-server`. Every collection gets REST routes from its schema, and `POST /query` takes any FenecQL statement. ```bash 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`. ```bash 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 ``` ```bash pip install fenecdb # the standard library alone ``` ```python 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` ``` ```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']); ``` ```bash go get github.com/fenecdb/fenecdb/integrations/go # the standard library alone ``` ```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` } ``` ```bash cargo add ureq@2 --features json cargo add serde_json ``` ```rust use serde_json::{json, Value}; fn query(q: &str, params: Value) -> Result { 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 => { /* ... */ } } ``` ```bash dotnet add package FenecDb # HttpClient and System.Text.Json alone ``` ```text 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]); var hits = await db.QueryAsync("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); ``` ```text static final HttpClient HTTP = HttpClient.newHttpClient(); static HttpResponse 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(); ``` ```text 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']); ``` ```text 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](https://fenecdb.com/docs/http-reference#routes), one per collection, with the filter in the [query string](https://fenecdb.com/docs/http-reference#filters). Here for an `articles` collection of titles, years and tags: ```bash 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. ```bash 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](https://fenecdb.com/docs/fenecql-search#facets), `{"rows": [...], "facets": {"brand": [{"value": "acme", "count": 12}, ...]}}`. A write answers `{"affected": n}`, and one with [`returning`](https://fenecdb.com/docs/fenecql-writing#pick) the rows it wrote, as a read does, with its `Fenec-Seq`: a job queue's claim ([Job queues](https://fenecdb.com/docs/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 /?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: ```python 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](https://fenecdb.com/docs/http-reference#changes) as a consumer, and `python -m fenecdb.relay` hands it on to another program or a webhook: see [Services and events](https://fenecdb.com/docs/microservices). ## 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. ```json {"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](https://fenecdb.com/docs/fenecql-writing#transfers)). ```json {"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. ```bash 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`](https://fenecdb.com/docs/fenecql-indexes#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](https://fenecdb.com/docs/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](https://fenecdb.com/docs/http-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](https://fenecdb.com/docs/languages) The same calls in Python, JavaScript, Go, Rust, .NET, Java, PHP and Ruby, and the apps that keep the database on the device. - [Server](https://fenecdb.com/docs/server) Running `fenec-server`: flags, durability, limits, containers. --- Source: https://fenecdb.com/docs/embedding # Embedded Rust Use fenecdb as a Rust library inside your own program. ## Open a file and query it Open a file with `fenec_core`, parse a query with `fenec_ql`, and run it. ```rust use fenec_core::prelude::*; use fenec_ql::parse; let mut db = fenec_core::fs::open("data.fenec")?; for stmt in parse("get docs near embed $1 limit 5")? { let r = db.execute_with(&stmt, &[Value::Vector(embedding)])?; } ``` What fenec-core depends on `fenec-core` is a plain Rust library with no dependencies. Parsing is `fenec-ql`; everything else is the engine. ## Reads and writes take different locks Reads run side by side; writes run one at a time. Several statements run as one block land together, or none of them does. ```rust let stmts: Vec = parse("put orders {total: 12}; set stock {n: 4} where id = 7")?; let block: Vec<(&Statement, &[Value])> = stmts.iter().map(|s| (s, &[][..])).collect(); match db.execute_block(&block) { Ok(answers) => { /* both landed, as one record */ } Err((at, e)) => { /* statement `at` failed: neither landed */ } } ``` **One process per file.** Two processes opening the same file and writing it corrupt it. There is no lock file and no advisory locking — the single-writer rule is yours to keep. How a block is begun, committed and put back `Database::query` takes a shared lock and `execute_with` the exclusive one. Every write is a block: what a statement writes lands in the file as one record, or — a hook's refusal, a document the schema refuses half way through a `put` of many — none of it does, and the database is as it was. Several statements are one block with `execute_block`, or between `begin` and `commit`, with `rollback` to put them back; whoever opens one holds the exclusive lock until it ends. A `create`, a `drop` or a `create index` in it is put back with the rest: the collection made goes, the one dropped comes back, the index goes. A `compact` rewrites the file and runs on its own, outside a block. FenecQL has no `BEGIN` or `COMMIT`: over HTTP a `/batch` is one block ([HTTP endpoint](https://fenecdb.com/docs/http)). ## Compaction is the caller's too An update leaves the version it replaced dead in the file until a `compact`. The server and the native library compact on their own; a program embedding the crate starts the same thread over its database, or asks from one of its own: ```rust use fenec_core::engine::{compact_when_due, CompactPolicy, Compactor}; let db = Arc::new(RwLock::new(fenec_core::fs::open("app.fenec")?)); // Looks every 5 s; compacts beside the queries once half the file is dead // and at least 64 MB, or sooner as the file nears the memory the system // has for its pages. Stops when dropped. let _compactor = Compactor::start(&db, CompactPolicy::default())?; // Or, from a thread you already have: compact_when_due(&db, &CompactPolicy::default()); ``` `Database::garbage` says what the file holds against what is live, without reading a document. ## Durability is the caller's job Call `sync` yourself: until you do, recent writes can be lost in a crash. The library never calls it for you. What sync and a storage error do File writes are buffered. If the process crashes before `sync` is called, the last writes are lost. In embedded use nothing calls it for you — `fenec-server` has the `--sync` policy and a shutdown hook precisely because the library does not. A storage error stops writes. Once the sink refuses an append, a `sync` or a rewrite, every later write — and every later `sync` — returns `Error::Io` until the file is reopened, and `Database::failure()` says what failed. A failed `fsync` is not retried: the kernel may already have dropped the pages, so a retry that succeeds would prove nothing. Reads go on, from memory. The same applies to the HNSW graph. It is derived data and is written only during `snapshot`, `compact` and `checkpoint`, never on the write path. Without a graph in the file it is rebuilt on open: 3.4 s at 100 000 × 128, against 9 ms with it. A crash does not cost the graph: the next open restores the one the last checkpoint wrote and applies the writes since — 0.07 s for 1 000 of them at 100 000 × 128, where the rebuild took 3.4 s. A process that has to answer before those are linked opens with `fs::open_serving`: the vectors written since go in unlinked, `near` measures each of them until `Database::link_pending(n)` has linked them, and the process calls it under its write lock a few at a time, as `fenec-server` does. `Database::unlinked()` says how many are left. ## Measuring the footprint `Database::memory_bytes()` tells you, for free, how much memory the engine's data takes. The process as a whole uses more. What memory_bytes counts and leaves out `Database::memory_bytes()` sums the segment bytes, offset indexes, vector arenas, graph links and every other index from counters they keep as they change, so it costs nothing to ask. It is not RSS — allocator slack, HNSW build buffers, upper-level neighbour allocations and the 2–3× transient peaks all sit outside it. Measured ratios are in [Limits](https://fenecdb.com/docs/limits#memory-and-scale). ## Features Two parts of the library are left out of a wasm build. | Feature | What it brings | | `std-fs` | Buffered file I/O — `fenec_core::fs`. Off in wasm builds. | | threads | The parallel HNSW build path, compiled out of wasm32 entirely. | Why now() errors on wasm32 `now()` errors on wasm32-unknown-unknown, which has no clock; time is passed in as a parameter there. ## Errors Every call returns the same error type, `Error`. The Error type and its HTTP statuses `Error` in `fenec-core/src/error.rs` is the single error type across the whole engine: allocation-free variants, no `Box`. `fenec-server` maps it onto HTTP statuses: 404 for a missing collection, 409 for one that exists or a taken id, 400 for a query or type error, 403 for a write refused, 500 for a storage error. ## Plugins Add your own functions and write hooks to the engine by installing a plugin. ```rust use fenec_core::plugin::{Plugin, Registry, ScalarFn}; struct Shout; impl ScalarFn for Shout { fn call(&self, args: &[Value]) -> Result { Ok(Value::Text(format!("{}!", args[0].as_text().unwrap_or("")))) } } struct MyPlugin; impl Plugin for MyPlugin { fn name(&self) -> &str { "mine" } fn init(&self, reg: &mut Registry) -> Result<()> { reg.register_fn("shout", Arc::new(Shout)) } } db.install_plugin(&MyPlugin)?; // get t select shout(name) ``` How the plugin registry works The registry takes after PostgreSQL's extension model. Scalar functions and write hooks in the shape of triggers can be registered; a plugin is installed once, and a second install of the same name is refused. `fenec-server --follow` guards its mirrored collection with such a hook. ## Module map Where each part of the engine lives in `fenec-core`. The modules and what each holds | `store` | Segments and the offset index | | `engine` | `Database`, `Collection`, replay, snapshot, compact, checkpoint | | `vector` | HNSW and the distance kernels | | `query` | `Statement` and plan execution | | `schema` `value` `codec` `json` | Types, the binary codec, the JSON parser | | `time` | Calendar arithmetic, integer only | | `changes` | The change ring behind subscriptions | | `plugin` | The registry | | `fs` | Buffered file I/O, behind `std-fs` | --- Source: https://fenecdb.com/docs/shell # The shell Open a database file and run FenecQL on it, one statement at a time. It is the quickest way to learn the language, and to look inside a file a server or an app keeps. ## Quickstart `fenec` comes beside `fenec-server` in every way of installing it ([Install](https://fenecdb.com/docs/install)), and the steps below need nothing else. Give it a file of its own, `scratch.fenec` here: not one a server has open, such as the Quickstart's `notes.fenec`, since two processes writing one file would corrupt it. - Open a file. It is made if there is none. ```bash fenec scratch.fenec ``` ```text fenecdb 0.1.12 — scratch.fenec `.help` for help, `.quit` to exit ``` - At the prompt, make a collection, each field typed and the ones you filter, order and search by indexed, and write two rows. A write ends at a `;`. ```text fenec> create collection notes (title text @text, year int @sorted) collection `notes` created fenec> put notes [{title: "Dunes", year: 2021}, {title: "Night at the oasis", year: 2024}]; 2 records (133.38µs) ``` - Read them back by a filter and an order, then search the words, a typo and all. The timings are yours. ```text fenec> get notes where year >= 2020 order year desc id title year -- ------------------ ---- 2 Night at the oasis 2024 1 Dunes 2021 (2 rows, 149.12µs) fenec> get notes select title match title "oasys" fuzzy title _score ------------------ ------ Night at the oasis 0.6224 (1 rows, 27.33µs) fenec> .quit ``` The file is the database: `fenec scratch.fenec` opens it again with its rows, and `fenec-server --file scratch.fenec` serves it once the shell has quit. [FenecQL](https://fenecdb.com/docs/fenecql) is a tour of the language in this shell, from a first write to a lookup and a total. ## Running statements ```bash fenec scratch.fenec # interactive fenec scratch.fenec -c 'get notes limit 5' # one statement, then exit echo 'get notes' | fenec scratch.fenec # from a pipe fenec scratch.fenec < seed.fenecql # a script fenec --version # fenec , and nothing opened ``` `--version`, or `-V`, prints the shell's version on stdout and exits 0 without opening a file; `fenec version`, with no dashes, opens a file named `version`, as any other word does. Releases up to 0.1.12 have no `--version`: the first line an interactive session prints names theirs. Inside the shell, a line that begins with a `.` is the shell's own: | Command | What it does | | `.help` | the summary of FenecQL and of these commands | | `.tables` | the collections | | `.stats` | storage statistics | | `.functions` | the registered functions and plugins | | `.save ` | writes a snapshot of the database to a file | | `.checkpoint` | rewrites the file and saves the vector graphs in it, so the next open is fast | | `.quit` | exits | ## Statements over several lines A statement may run over several lines, and the shell runs it only once nothing after it can go on with it. Typed, a read or a schema change written on one line runs at Enter. A write, and a read written over several lines, wait for the next line: one that goes on with them — `where`, `order`, `and` — joins them, and an empty line or another statement runs them first. A `;` after a statement runs it at once. So `del notes` with `where id = 3` on the line under it deletes one row. A `.` command leaves a waiting statement waiting — `.tables` to look a name up before the `where` — and `.quit` leaves it unrun. Ending each write with a `;`, as the session above does, is the habit that behaves the same everywhere, including in releases up to 0.1.10: they run every line whose brackets close, so there `del notes` deletes every row before a `where` under it is refused. How a pipe, a paste and -c are read Piped from a file, a statement runs once the next one begins, and a paste is read the same way, its lines all there at once — a `.` command in either waits behind the statement before it, and runs once that has. `-c` reads its text whole, as `POST /query` does, so a text that does not parse runs nothing. A line that begins no statement — a `where` after a read that already ran at Enter, a `wher` mistyped, a clause written past a `;` — is refused, and so is the statement before it, which it may have meant to go on with: `del notes; where id = 3` deletes nothing. A paste the terminal delivers in pieces further apart than 40 ms is read as typed lines, and a pasted statement whose last line has no newline yet waits for Enter like a typed one. ## What else the program does The same `fenec` is the tool for a file or a server beyond the prompt. `fenec --help` lists each with its flags. - **`fenec import`** copies a SQLite file or a PostgreSQL table into a collection, and `--follow` keeps a PostgreSQL copy up to date: [Import](https://fenecdb.com/docs/import). - **`fenec backup`, `archive`, `restore`, `prune` and `verify`** take a running server whole, keep its writes and rebuild it as it stood at a moment: Replication. - **`fenec types`** writes TypeScript, Python, Go or C# types from a database file: [TypeScript](https://fenecdb.com/docs/javascript#typescript). - **`fenec mcp`** serves a file, or a server, to an agent over stdio: [Agents and MCP](https://fenecdb.com/docs/mcp). --- Source: https://fenecdb.com/docs/integrations # Integrations Use fenecdb from LangChain, LlamaIndex, LangChain.js, the Vercel AI SDK and React, each tested the way its framework tests its own, from Go and .NET through their SDKs, and embedded in Swift, Kotlin and Dart apps. ## LangChain A LangChain vector store over fenec-server's HTTP endpoint. Install it, point it at the server, and use it like any other store. ```bash pip install "fenecdb[langchain]" ``` ```python 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"}) retriever = store.as_retriever(search_kwargs={"k": 8}) ``` - **Filter only on declared fields:** list them in `metadata_fields`. - **Keyword and hybrid search:** pass `full_text=True`, then `mode="text"` or `"hybrid"`. How the LangChain store maps documents and filters A document is a row: its id, its text, its metadata as JSON and its vector under `@hnsw`, in a collection created on the first write, once the embedding's size is known. Adding an id that is already there replaces it — the old row out and the new one in, sent as one `/batch` and run under one write lock, so no reader sees the id twice or not at all. A metadata field named in `metadata_fields` gets a column of its own under `@hash`, and `filter` takes equality and `$in` over those: `{"source": "ops"}` becomes `where source = $2`, answered by the index before `near` looks for neighbours among the rows that passed. A filter on a field not declared is refused rather than applied to the JSON after the search, which would return fewer than `k` rows and not say so. The score is fenecdb's own, the one the HTTP endpoint and the browser module return: the cosine similarity under `cosine` (the default), the distance under `l2`, the product under `dot`. `similarity_search_with_relevance_scores` maps them the way LangChain's own functions do. ```python store = FenecVectorStore(embeddings, "handbook", url=..., token=..., full_text=True, quant="int8") store.similarity_search("compact dead records", k=4, mode="text") # BM25 alone store.similarity_search("how do I compact", k=4, mode="hybrid") # both, fused retriever = store.as_retriever(search_kwargs={"k": 8, "mode": "hybrid"}) ``` With `full_text=True` the text is indexed for BM25 as well (`@text`, added to a collection made before without it), and a search takes `mode`: `"text"` ranks by the words alone with `match` and embeds nothing, and `"hybrid"` runs `match` and `near` each to their own depth and fuses the two rankings ([`fuse`](https://fenecdb.com/docs/fenecql-search#hybrid)). Their scores are BM25's and the fusion's, not similarities. `quant`, `"int8"` or `"bit"` (`cosine` only), keeps the graph over codes, and `near` still orders by the vectors themselves. ## LangChain.js The same store from JavaScript. It runs over a database in the page, with no server, or over fenec-server's HTTP endpoint. ```js import { Fenec, connect } from '@fenecdb/web'; import { FenecVectorStore } from '@fenecdb/langchain'; // The database in the page -- or behind fenec-server: connect('http://127.0.0.1:8080') const db = await Fenec.open('./fenec.wasm'); const store = new FenecVectorStore(embeddings, { client: db, collection: 'handbook', metadataFields: { source: 'text' }, fullText: true, }); await store.addDocuments(documents); await store.similaritySearch('how do I compact', 4, { source: 'handbook' }); await store.search('compact', 4, { mode: 'hybrid' }); const retriever = store.asRetriever({ k: 4 }); ``` How the JavaScript store takes a client, filters and search modes The Python store's collection, from JavaScript: a document is a row of its id, text, metadata as JSON and vector under `@hnsw`, made on the first write; `metadataFields` get columns of their own under `@hash`, and `filter` takes equality and `$in` over them; with `fullText`, `search(query, k, {mode})` ranks by the words (`'text'`) or both (`'hybrid'`). `client` is anything with `run(sql, params)`: a `Fenec` keeps the whole RAG index in the page, no server, and a `FenecHttp` the server's. Writing an id already there replaces it — two statements, which over HTTP are two requests. ## LlamaIndex A LlamaIndex vector store over fenec-server's HTTP endpoint, with metadata filters, deletion by document, and text and hybrid search. ```bash pip install "fenecdb[llama-index]" ``` ```python 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") ``` - **Hybrid search:** pass `full_text=True` and query with `vector_store_query_mode="hybrid"`. How nodes, filters and deletes map A node is a row with its document's id beside it, so `index.delete_ref_doc(...)` is one `del` over a hashed field. Filters take `==`, `!=`, `<`, `<=`, `>`, `>=`, `in` and `nin` over the declared fields, joined by `and` or `or` and nested; `node_ids` and `doc_ids` narrow the same way. `get_nodes`, `delete_nodes` and `clear` are there; `delete_nodes()` with neither ids nor filters deletes nothing — everything is `clear`, asked for by name. With `full_text=True` the text is indexed for BM25 as well, and a query takes the modes that need it: `TEXT_SEARCH` ranks by the words alone (`match`), and `HYBRID` runs `match` and `near` each to their own depth — `hybrid_top_k`, or `sparse_top_k`, deep — and fuses the two rankings ([`fuse`](https://fenecdb.com/docs/fenecql-search#hybrid)). The fusion adds ranks rather than scores, so `alpha` has nothing to weigh and is not read. `quant` is taken as the LangChain store takes it. ```python store = FenecVectorStore("handbook", url=..., token=..., full_text=True) retriever = index.as_retriever(similarity_top_k=4, vector_store_query_mode="hybrid") ``` ## Vercel AI SDK Three functions to copy from `integrations/ai-sdk/rag.js`: `index` your documents, `retrieve` for a question, and `searchTool` for a model to call. ```js import { generateText, stepCountIs } from 'ai'; import { openai } from '@ai-sdk/openai'; import { Fenec } from '@fenecdb/web'; import { index, retrieve, searchTool } from './rag.js'; // integrations/ai-sdk/rag.js const db = await Fenec.open('./fenec.wasm'); const model = openai.textEmbeddingModel('text-embedding-3-small'); await index(db, { collection: 'docs', model, documents }); // [{text, source?}] 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?', }); ``` The three functions in rag.js The AI SDK has no vector store interface to implement — it has embedding models and `tool` — so the integration is a file of three functions to copy, `integrations/ai-sdk/rag.js`: `index` embeds with `embedMany` and writes the documents, their text under `@text` and their vectors under `@hnsw`; `retrieve` embeds the question with `embed` and answers with `near`, or with `hybrid` the words and the vector fused, kept to a `source` through its `@hash` index; `searchTool` is a `tool` a model calls with a query. It takes a `Fenec`, so the index lives in the page or a Worker, or a `FenecHttp` for the server's. ## React `useLiveQuery` renders a query over the database in the page — a `Fenec` of its own or a synced replica — and runs it again after every write it may read. No render waits on the network. ```ts import { sync, type FenecSync, type Row } from '@fenecdb/web'; import { FenecProvider, useFenec, useLiveQuery } from '@fenecdb/react'; import type { FenecSchema } from './fenec-schema.js'; // fenec types, over the server's file type TaskRow = Row; const db = await sync({ url: 'http://127.0.0.1:8080', shapes: [{ collection: 'tasks' }] }); function Open() { const db = useFenec>(); const rows = useLiveQuery(db.from('tasks').where('done', false).order('title')); if (rows === undefined) return ; return rows.map((t) => ); } ``` - **No server needed:** `` makes the page's own database the app's state ([State in the page](https://fenecdb.com/docs/javascript#state)). - **No module needed:** ``, from `@fenecdb/web/client`, runs each query on the server and again after every write to a collection it reads. - **It is `undefined`** until the first answer. - **A failing query throws** to the nearest error boundary. | Provider | Where a query runs | A render after a write | Loads | | `connect(url)`, `@fenecdb/web/client` | on the server, each time | waits for the server: the subscription's change, then the query's round trip | no module, 9 KB brotli of client | | `sync({ url, shapes })` | in the page, over the replica | waits for nothing: this tab's write is local, another client's arrives on the stream | `fenec.wasm`, 273 KB brotli | | `Fenec.open()` | in the page, over its own database | waits for nothing | the same `fenec.wasm` | How the hooks run a query again Over a replica or a database in the page the rows come from the page, so no render waits on the network: `live` runs the query again after every write it may read — a write of this tab's, or one the server streamed into a replica from another client — and the component renders what it answers. Over `connect` the query runs on the server, again after each write the server's subscriptions report to a collection it reads; a FenecQL text names those with `{ collections }` there, since nothing in the page can know what a text reads. A FenecQL text works too, on the provider's database: `useLiveQuery('get todos count', { collections: ['todos'] })`. `useLiveQuery` is `undefined` until the first answer, and subscribes by the query's text and parameters, so the query built again on every render, which is the natural way to write one, does not subscribe again unless it asks something else; the rows of the query it asked before are not handed out as this one's. A query that fails throws while rendering, to the nearest error boundary. A query that asks for [facets](https://fenecdb.com/docs/fenecql-search#facets) has them on its rows, `rows.facets.brand`, counted over every match: a results list and its filter sidebar from one hook, live alike. It lives in `integrations/react`, with React as its one peer dependency. ## Go and .NET An SDK each over fenec-server's HTTP endpoint, each its standard library alone, beside Python's `fenecdb` and JavaScript's `@fenecdb/web`. ```bash go get github.com/fenecdb/fenecdb/integrations/go dotnet add package FenecDb ``` - **The same surface in both:** a statement and its rows, typed rows, writes with the change they left the database at, atomic batches, an idempotency key, a read that waits for a write on a replica, reads sent to a tenant's read replica near the client, after its own writes (`WithReadsNearest()`, `ReadsNearest`; Read replicas), a subscription, the change stream, a tenant, the health check, and a typed error carrying the status. - **The query builder,** as Python's and JavaScript's have it: `db.From("docs").Where("year", ">=", 2024).Near("embed", v).Limit(5)`, immutable, ending in `Rows`, `First`, `Count` or a write. All four build the same text from the same chain, held to one golden file ([The builder](https://fenecdb.com/docs/languages#builder)). - **Vectors round-trip to the bit:** a `[]float32`, `float[]` or `ReadOnlyMemory` goes out as the decimals that read back as each float. Each language's example is on [Your language](https://fenecdb.com/docs/languages), and the API in `integrations/go` and `integrations/dotnet`. How the Go and .NET SDKs handle one float, subscriptions and timeouts The server reads a number as a double and rounds it to a float, and one float, `7.038531e-26`, has a shortest text whose double lands exactly between it and the float above: Go's `encoding/json` wrote it so, and the server stored the float above. Both SDKs write that one as its double's text, as the server itself writes it, and their tests try one float in 4 099 over every exponent and read a vector holding it back from a server. A subscription takes the REST surface's filter (`year=gte.2024`, `where=`, `select=`), not a statement with parameters: the stream is `GET //changes`. A request is bounded by the client's timeout through its context or token, never the HTTP client's own, which would cut a subscription short. ## Swift, Kotlin and Dart Not over HTTP: the database in the app, a file on the device, through the native library (`crates/fenec-ffi`). A live query drives the UI -- a SwiftUI observable, a Kotlin `Flow` for Compose, a Dart `Stream` for a `StreamBuilder`. ```text .package(url: "https://github.com/fenecdb/fenecdb", from: "0.1.12") // Swift: product FenecDB implementation("com.fenecdb:fenecdb-android:0.1.12") // Kotlin, Android flutter pub add fenecdb_flutter // Flutter ``` - **The query builder,** the same seven ways, held to the one golden file ([The builder](https://fenecdb.com/docs/languages#builder)). - **Vectors as their bytes:** a `[Float]`, `FloatArray` or `Float32List` goes to the library as its `f32`s, never as digits. - **Live queries,** run again once a burst of writes to a collection they read. The examples, where the file goes and what a write survives are on [Mobile and native apps](https://fenecdb.com/docs/mobile). ## Their tests Each integration runs its framework's own tests, or the ones its framework holds its integrations to, against a real fenecdb. | Integration | Held to | Run with | | LangChain | LangChain's standard vector store suite (`langchain-tests`), sync and async, and filters, relevance, `from_texts`, the text and hybrid modes and int8 codes besides | `make python-test`: fenec-server built and started here, the tests from a `python:3.13` container | | LlamaIndex | the tests LlamaIndex's own vector store integrations run — the class, a write and a nearest match, deletion by document, nodes by id and by filter, every filter operator, an index on top — and the text search and hybrid modes | | LangChain.js | what LangChain.js's own vector store integrations are held to — documents in and found again with their ids and scores, replaced under an id, deleted, filtered, `fromTexts`, a retriever — and the text and hybrid modes, each over a database in the page and over fenec-server's HTTP endpoint; LangChain.js publishes no standard suite | `make langchain-test` | | Vercel AI SDK | the SDK's own mock models: documents embedded with `embedMany` and found again with `near`, kept to a source and fused with the words, and a model that calls the search tool in `generateText` and answers from what it returned | `make ai-sdk-test` | | React | a stand-in database for the contract, and a real fenec-server, a replica and `connect` for a row another client writes | `make react-test` | | Go | a primary with a token, a JWT policy and the change stream, a replica and a node of tenants, started by the tests: `near` with exact scores, parameters of every type, the errors, a batch put back whole, an idempotency key, `After` on the replica, a subscription, `/_changes`, a tenant and a scoped token; the query builder against every case of `integrations/builder-golden.json`, and its queries' answers against the same statements sent as text | `make go-test` | | .NET | the same, with xunit | `make dotnet-test` | | Python client | the query builder against every case of `integrations/builder-golden.json`, sync and async, and its queries' answers against the same statements sent as text | `make python-test` | | Swift | the engine through the library -- `near` with exact scores, rows decoded, a json field's list as written, every kind of error, a file opened again and refused a second open, many tasks at once -- the query builder against every case of the golden file and its answers against the statements as text, live queries as a stream and as observables, a burst of writes running each once; on macOS, and in CI on an iOS simulator | `make swift-test` | | Kotlin | the same with JUnit on the JVM, the library built for Linux with its JNI functions; the AAR built with the NDK in CI | `make kotlin-test` (Docker) | | Dart and Flutter | the same with `dart test`, and the Flutter plugin's test and its example built for Android and the iOS simulator in CI | `make dart-test` | The graph bug LangChain's suite found LangChain's suite writes the same documents twice under the same ids, and that is how it found a bug in the graph: a node whose every candidate neighbour had been deleted was linked to nothing, so a collection deleted and written again answered `near` with nothing. It is fixed in the engine, with the test that shows it. --- Source: https://fenecdb.com/docs/mcp # Agents and MCP An agent — Claude Code, Cursor, Claude Desktop, one you build — searches and remembers through tools it calls. fenecdb serves those tools over MCP, the Model Context Protocol: on its HTTP endpoint, or as a process the agent starts. Each tool runs a statement as `/query` runs one, under the token the agent was given: a scoped token's agent reads its own rows and no others, the tools that write are off unless the server is told, and an answer too large for a model's context is refused with what to narrow — never cut, since a model told nothing of the cut takes half an answer for the whole. ## Turn it on On a server, `--mcp` serves the endpoint at `POST /_mcp` (and `/t//_mcp` on a tenant node, which a router forwards like any tenant path) — an underscore, as `/_metrics` has one, so that no collection's path is taken: ```bash fenec-server --file notes.fenec --http-token "$FENEC_TOKEN" --mcp fenec-server --file notes.fenec --http-token "$FENEC_TOKEN" --mcp-write --mcp-memory memories ``` Over a file, `fenec mcp` is the server as a process the agent starts, speaking stdio — a message a line on stdin, an answer a line on stdout, its log on stderr: ```bash fenec mcp --file notes.fenec --memory memories fenec mcp --url http://127.0.0.1:8080/_mcp # in front of a server, the token from FENEC_TOKEN ``` `--file` holds the database itself and takes `notes.fenec.lock` while the session lasts — a server or an app over the same file is refused, as it is refused while they hold it: two processes writing one file corrupt it. Each write is on the disk before it is answered. `--read-only` takes no lock and writes nothing, and reads the file as it stood when the session began, beside a server writing it; to read what a server serves now, use `--url`. Over `--url` the tools are the server's, under its options and the token's rules, and the bridge decides nothing. ## Claude Code ```bash claude mcp add fenecdb -- fenec mcp --file notes.fenec --memory memories claude mcp add --transport http fenecdb http://127.0.0.1:8080/_mcp --header "Authorization: Bearer $FENEC_TOKEN" claude mcp list # fenecdb: ... - ✓ Connected ``` With `--scope project` each goes into the project's `.mcp.json`, which Claude Code loads for whoever opens it — the token left in the environment: ```json { "mcpServers": { "fenecdb": { "type": "stdio", "command": "fenec", "args": ["mcp", "--file", "notes.fenec", "--memory", "memories"] } } } ``` ```json { "mcpServers": { "fenecdb": { "type": "http", "url": "http://127.0.0.1:8080/_mcp", "headers": { "Authorization": "Bearer ${FENEC_TOKEN}" } } } } ``` ## Cursor ```json { "mcpServers": { "fenecdb": { "command": "fenec", "args": ["mcp", "--file", "notes.fenec", "--memory", "memories"] } } } ``` ```json { "mcpServers": { "fenecdb": { "url": "http://127.0.0.1:8080/_mcp", "headers": { "Authorization": "Bearer ${env:FENEC_TOKEN}" } } } } ``` ## Claude Desktop Claude Desktop starts its tools as processes, so a server is reached through the bridge. An app started from the dock does not see a shell's `PATH`: name `fenec` by its full path. ```json { "mcpServers": { "fenecdb": { "command": "fenec", "args": ["mcp", "--url", "http://127.0.0.1:8080/_mcp"], "env": { "FENEC_TOKEN": "the token" } } } } ``` Every configuration on this page is loaded by a test (`integrations/mcp/test.js`, `make mcp-test`) and each of its servers called with the official TypeScript MCP client. ## The tools | tool | what it does | takes | | `schema` | the collections the token may read, each as the FenecQL that makes it, and — to the server's token — how many rows each holds | `collection` | | `search` | rows ranked best first: by words over an `@text` field (BM25), by an embedding over an `@hnsw` field, or both fused; each row with `_score` and, by words, a `snippet` with the words found in `**bold**`; `fuzzy` matches words a typo away ([typos and prefixes](https://fenecdb.com/docs/fenecql-search#typos)), `prefix` the last word as the start of one | `collection`, `text`, `fuzzy`, `prefix`, `vector`, `filter`, `params`, `limit`, `fields`, `text_field`, `vector_field` | | `query` | one FenecQL read: `get` with any clause, aggregates, `count`, `lookup`, `explain` | `query`, `params`, `include_vectors` | | `get` | rows by id; the ids with no row come back in `missing` | `collection`, `ids`, `fields` | | `write` | one `put`, `insert`, `set` or `del`, with `require n` if it must touch n rows (`--mcp-write`) | `statement`, `params` | | `remember` | keeps a memory (`--mcp-memory`) | `text`, `vector`, `tags`, `expires_in` | | `recall` | the memories that best match, or the latest | `text`, `vector`, `tags`, `limit` | | `forget` | deletes a memory by its id | `id` | A tool answers JSON — `{"rows": [...], "count": n}` for a read — as its structured content and, written out, as the text a model reads. Vector fields are left out of the rows: a 768-dim embedding is some 7 KB of JSON a row, which a model reads as noise and pays for. `fields` names the ones to keep, and `query`'s `include_vectors` keeps them. A search's call and its answer: ```json {"name": "search", "arguments": {"collection": "docs", "text": "fox", "filter": "year >= $1", "params": [2024]}} ``` ```json {"rows": [{"id": 3, "title": "cat", "body": "the cat watches the fox", "year": 2025, "snippet": "the cat watches the **fox**", "_score": 0.67}], "count": 1} ``` **Embeddings are the client's to compute.** fenecdb stores and searches vectors and runs no model: an agent that has one passes `vector`, made with the model the collection was filled with — a vector of another length is refused, naming the field's. Without one, search by words. **Values go in `params`, never into the text.** A filter is a FenecQL condition, `year >= $1 and tags has $2`, parsed on its own and refused unless it is a condition and nothing more — no clause after it, no second statement, no parenthesis closing on the statement around it — and it goes into the statement the tool builds as the condition parsed, never as text. Names are checked against FenecQL's rule for one. **What a tool refuses comes back for the model to correct**, as its result with `isError`: an argument it does not take, a vector of the wrong length, a statement the token's rules refuse, an answer past a bound — each saying what to change, and with the status `/query` would have answered it with. A call that is not one — an unknown tool, a malformed message — is a JSON-RPC error. ## Bounds | flag | default | past it | | `--mcp-max-rows` | 100 | refused, 413: a `query` with no `limit` is asked for one row more than the bound, so a larger answer is known without being fetched | | `--mcp-max-bytes` | 64K | refused, 413, measured on the JSON the model would read | | `--mcp-calls-per-sec` | 20 | refused, 429, with when to call again: a scoped token's subject, or an address for the server's token | A hundred rows and 64 KB are about 16 000 tokens of JSON, inside what Claude Code takes from one tool before it warns. A tenant node's quotas hold an agent's calls as they hold a client's requests. ## Security - **The token is the request's.** The endpoint takes the bearer token `/query` takes: the server's token reads and writes everything, and a JSON Web Token is held to its [policy](https://fenecdb.com/docs/http-access#scoped-access), ANDed into every statement a tool runs — so an agent given a scoped token sees exactly what its `/query` sees. `schema` lists only the collections it may read and counts none of their rows, and on a tenant node the token reaches only the tenant its claim names. - **Writes are off unless asked for.** `--mcp-write` adds `write`, and `--mcp-memory` the memory tools alone. `write` runs `put`, `insert`, `set` and `del`, each held to the token's grants and to its policy as the row it writes; a schema change or a `compact` is never made over MCP, by any token. A server with `--http-read-only` takes no write over MCP either. - **Where a request comes from is asked before its token.** An `Origin` other than the server's own, or the one `--http-cors` names, is refused (403). A server that asks for no token answers only a `Host` naming this machine — `localhost` or an address — so a page whose name was rebound to 127.0.0.1 reaches nothing. - **No TLS.** fenecdb speaks none: on an open network put a TLS terminator in front of the server, and give the agent's client the `https` URL. The stdio bridge speaks plain HTTP only. - **An agent's writes are audited.** With `--audit` each call of `write`, `remember` or `forget` is a line (`"event":"mcp"`, the tool, whether it failed), beside the schema changes and the refused tokens. `/_metrics` counts calls in `fenec_mcp_tool_calls_total` by tool and outcome — no collection is a label. ## Memory `--mcp-memory memories` gives an agent a memory that outlives its conversations: `remember` keeps a sentence, `recall` finds the ones that match what it is asked, and `forget` lets a wrong one go. The collection is made at the first memory, with a vector field if that memory came with a vector — and given one at the first that does: ```fenecql create collection if not exists memories (text text required @text, tags [text], owner text @hash, at timestamp @sorted, expires timestamp @ttl(1ms), vector vector<3> @hnsw(cosine)) ``` The text is searched by its words (`@text`), so an agent with no embedding model still recalls: BM25 finds the deploy runs on fridays for when do we deploy. With vectors — `vector` of the embedding's length; 3 here so the examples fit — recall fuses the words with the meaning. `expires` is when a memory goes: `@ttl(1ms)` takes a row out of every read the moment its time passes, and the sweeper deletes it; a memory with none is kept for good. `remember` runs `insert into memories {text: $1, tags: $2, at: now(), expires: now() + $3, vector: $4}`, its expiry the milliseconds `expires_in` names (`30m`, `12h`, `7d`). With the values written in: ```fenecql insert into memories {text: "the deploy runs on fridays", tags: ["ops"], at: now(), expires: null, vector: [0.9, 0.1, 0]} -- → 1 insert into memories {text: "staging is in eu-west-1", tags: ["ops"], at: now(), expires: null, vector: [0, 0.2, 1]} -- → 1 insert into memories {text: "the user is debugging the login page", tags: ["session"], at: now(), expires: now() + 10000} -- → 1 ``` `recall` with words alone, with words and a vector, by a tag, and with nothing — the latest first: ```fenecql get memories select text, tags, at match text "when do we deploy" limit 5 -- → "the deploy runs on fridays" get memories select text, tags, at match text "which region" near vector [0, 0.1, 1] fuse limit 5 -- → "staging is in eu-west-1" get memories select text where tags has "session" limit 5 -- → "the user is debugging the login page" get memories select text, tags, at order at desc, id desc limit 5 -- → "the user is debugging the login page" ``` Ten seconds on, the session's memory is out of every read: ```fenecql get memories where tags has "session" count -- → 1 -- 20 s later get memories where tags has "session" count -- → 0 ``` Every FenecQL block on this page is run, statement by statement, by a test (`crates/fenec-core/tests/mcp_docs.rs`), and the collection above is held to the one `remember` makes. **Each user's memories apart.** A policy line keeps each token's memories its own: a memory written without an owner gets the token's subject, and a recall reads that subject's alone. The collection is made by the server's token, once — a scoped one may not make collections, and its `remember` says what to run. ```text memories read,write where owner = $jwt.sub ``` **An example agent.** `examples/mcp-agent` is Claude with these tools over `fenec mcp --file`, through the Anthropic SDK's tool runner: told something in one run, it recalls it in the next. ```bash cd examples/mcp-agent && npm install node agent.mjs "I indent with tabs, and the deploy runs on Fridays" node agent.mjs "when does the deploy run?" ``` ## The protocol The revision is **2026-07-28**, the stateless one: each request names its revision and the client's capabilities in `_meta`, there is no handshake and no session, and `server/discover` says what is here. Beside it are the handshake revisions clients still open with — **2025-11-25**, **2025-06-18** and **2025-03-26**, an `initialize` first — served the same way and without a session. A request carrying `_meta`'s version is served by 2026-07-28's rules, an `initialize` by the revision it agrees on. - Over HTTP a 2026-07-28 request's headers — `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name` — must say what its body says (400, `-32020`); a revision not served is refused naming those that are (400, `-32022`); an unknown method is a 404 with a JSON-RPC body. An answer is one JSON object, never a stream: no tool here has anything to say before its answer. `GET` and `DELETE` are 405, and a batch is refused. - Resources and prompts are not offered. A collection as a resource is what `schema` answers already, and the clients here put tools before the model and resources behind a person's click; a prompt is a recipe this page gives. ## What it costs A tool call runs the statement `/query` would, and adds reading the JSON-RPC message, building the statement and writing the answer twice — as structured content and as its text. Over 10 000 rows (`make mcp-bench`, an M1): | | `/query` | MCP | | a row by id, in process, `query` tool | 1.2 us | 3.8 us | | a row by id, in process, `get` tool | 1.2 us | 4.4 us | | two words, top ten with snippets, in process | 261 us | 263 us | | a row by id over HTTP, p50 | 17.7 us | 21.9 us | | two words over HTTP, p50 | 316 us | 323 us | A few microseconds a call, against the seconds a model's turn takes. A server without `--mcp` asks one comparison more of a request's path and nothing else: `make requests-bench` in eight runs taken in turns against the server before it, a row by id answered one client in 0.027 ms against 0.028 at the median and eight in 122 000 requests a second against 111 000, inside the runs' own spread. --- Source: https://fenecdb.com/docs/sync # Sync Keep a copy of your data in the browser or an app that reads without the network, writes instantly and stays up to date with the server. ## Start a replica Call `sync` with the server and the rows you want. Then query it with the same builder you use anywhere else. ```ts import { sync } from './fenec.js'; import type { FenecSchema } from './fenec-schema.js'; // fenec types, over the server's file const db = await sync({ url: 'http://127.0.0.1:8080', shapes: [{ collection: 'tasks', where: { status: 'open' }, key: 'key' }], }); await db.ready(); // read: from the local copy, no network const rows = await db.from('tasks').where('priority', '>=', 3).rows(); // live query: re-runs on every change const stop = db.live(db.from('tasks').order('priority', 'desc'), draw); // write: to the local copy first, instantly, then to the server await db.from('tasks').insert({ title: 'new', status: 'open' }); ``` **The replica loads `fenec.wasm`**, the full module, unless given another, so its `near` walks a graph. What sync opens, and where a query binds A copy on the client, changes streaming from the server, optimistic writes. In fenecdb that is not a library — it is an endpoint and a transport, because the same engine already runs on both sides. With no module given, `sync` opens `./fenec.wasm` beside the page, its collation data from `collate/` beside it; `wasm` takes another URL, the bytes or a compiled module, and `local` a database already open. The query code does not change. `db.from(...)` is the same builder producing the same FenecQL; the only difference is where it is bound. ### The server owns the schema A replica takes its collections as the server declares them. Code that declares its tables ([Schema in code](https://fenecdb.com/docs/javascript#schema)) hands them over too, and they are checked against the server's, never applied: ```ts import { sync } from './fenec.js'; import { todos } from './tables.js'; const db = await sync({ url: 'https://todos.example.com', schema: { todos }, shapes: [{ collection: todos, key: 'key' }], }); ``` - **What the code declares must be there.** A collection, a field or an index the server lacks, or one of another type, refuses the open: a `FenecError` naming each difference. - **What the server holds beside it is its own.** A field or a collection the code does not declare is fine: an older app keeps working while the server moves ahead. - **Nothing is applied, not even an addition.** The schema changes where it lives, on the server: its owner's migrations, a deploy step's `connect(url, { schema, migrations, migrate: true })`. How the schema is compared The comparison is the server's engine (`POST /_schema/plan?mode=follow`), the same that checks a page's own database at an open, so the two say the same things in the same words. A replica reconciles rows by its server's field order and types, which is why it never declares a collection of its own: a field added in the page and not on the server could not be decoded either way. ## Native apps sync too An iOS, Android or Flutter app keeps its replica in a file on the device, with the same shapes, the same optimistic writes and the same catching up -- and its writes wait in the file while the server cannot be reached. Opening the file as a replica is the one line that changes: `Fenec.sync` in Swift and Kotlin, `Fenec.openSynced` in Dart ([Sync with a server](https://fenecdb.com/docs/mobile#sync)). ## The stream carries state, not transactions The server sends the current state of each row that changed, not a log of every write. That is what a replica needs; it is not an audit log. Why the stream is a clock, not a log The classic route is a transaction log: every write is an event and the subscriber replays them. fenecdb sends the current state of what changed instead — the stream is a clock, not a record. That is right for a replica and wrong for anyone who wanted an audit log. ## A shape is a set, not a window A shape picks the rows of one collection the client keeps, by a filter the server applies. It cannot be "the latest 100": `order`, `limit`, `offset` and `count` are refused. Why a shape refuses order, limit and offset The whole database is in memory and the WASM32 address space is 4 GB, so a client cannot pull an entire collection. A subscription is a subset and the filter is applied on the server. **`order`, `limit`, `offset` and `count` are refused in a subscription** with a 400. A shape like "the latest 100 rows" looks correct but is not: when a new row arrives the oldest has to drop out, and an incremental diff cannot express that. An explicit error beats a silently wrong stream. The shape condition uses the `?field=op.value` form, so there is no `or` group and no function call. The reason is escaping: a free `where=` would turn into string concatenation, exactly what the builder avoids. Escaping goes to the transport via `URLSearchParams` and types go to the server, leaving no injection surface. **One collection per shape.** The seed has to mean "this is the whole of this collection", or the cleanup step becomes ambiguous. ## Optimistic writes A write shows up locally at once, then goes to the server. It is kept until the server answers: if the server refuses it, the local change is undone; if the server cannot be reached, it waits and goes again. ```ts const p = db.from('tasks').insert({ title: 'new', status: 'open' }); await db.from('tasks').where('title', 'new').first(); // already here await p; // the server has it ``` - **Inserts need a `key`,** a `text @hash` field, to match the local row with the server's. Without one, an insert waits for the server. - **Updates and deletes need no key:** they work by the filter, by id or anything else -- a row an insert made before the server answered included. - **Writes go one at a time, in order,** each under an `Idempotency-Key` it keeps until it is answered. - **An increment lands from every replica.** `update({ n: inc(1) })` is worked out over the replica's row at once and sent as the statement, which the server works out again over its own: two replicas that each increment offline leave the server 2 up, and both replicas come to its sum. How an optimistic write is applied and undone A write is three steps and the order matters: first gather what undoes the local change, then apply it locally, then queue it for the server. If the server refuses, the local side rolls back fully — no network needed, the undo is already in hand. The queue and the undo are kept in the replica itself, in collections of the sync's own (`_sync_queue`, `_sync_temps`, `_sync_shapes` with the cursors), as an app's file keeps them ([Sync with a server](https://fenecdb.com/docs/mobile#sync)). Everything up to the first `await` is synchronous. An `async` function body runs synchronously until its first `await`, so the optimistic row is local before the caller awaits the promise. A single microtask in between would have broken the "visible instantly" promise. **Reconciliation by key.** The id space belongs to the server and the client cannot know it in advance, so an optimistic row gets a temporary id starting at 252 — far from the server's range and below `Number.MAX_SAFE_INTEGER`. What matches the two is a **business key**: when a row with the same `key` arrives from the subscription, the temporary one is dropped. An update or a delete that reaches a row the server has not sent yet is sent a second way as well, by that key -- `set tasks {..} where key in [..]` beside the statement -- since the temporary id is the page's alone. An insert the shape does not hold leaves its temporary row once the stream is past the change the server answered it at (`Fenec-Seq`). **An insert into a keyless shape is not applied optimistically**, unless it names its id. With nothing to reconcile, the row would stay a local duplicate; one round trip of latency beats a silent copy. `key` must be `text @hash`. Writes go one at a time because a write that landed after one written after it would leave the server holding the earlier. The promise a write returns settles with the server's answer, so it waits through an outage; the row is in the replica from the start, and nothing needs to await it. ## Refusals, retries and offline Only a refusal puts a write back. Anything else that keeps the server from answering keeps the write, and it goes again until the server answers it -- once, whatever the network did meanwhile. | The server answers | The write | | 2xx | landed: it leaves the queue, its promise resolves | | nothing (the network failed), `408`, `429`, `5xx` | kept and sent again under its key, after 250 ms doubling to 15 s -- or after the `Retry-After` the server names, where that is longer: a tenant's quota, a tenant being moved or failed over | | `401` | kept; nothing is sent until a new token (`tokenProvider`, `setToken`) | | `507` naming a tenant's quota of `bytes` or `rows` | refused: put back, its promise rejects, `onRefused` is told with `e.quota`, and the writes behind it go on | | any other `4xx` | refused: put back, its promise rejects, `onRefused` is told | ```ts import { sync } from './fenec.js'; const db = await sync({ url: 'https://api.example.com', shapes: [{ collection: 'tasks', key: 'key' }], persist: 'tasks', // writes survive a reload tokenProvider: () => auth.refresh(), // asked on a 401 onRefused: (e) => toast(e.quota ? 'This workspace is full' : `${e.status}: ${e.message}`), }); await db.pushed(); // every write answered ``` Offline, reload, backoff, quota and failover **Once, by the key.** A write whose answer was lost -- the request reached the server, the response never came back -- is sent again under the `Idempotency-Key` it was first sent with, and the server answers it as it answered the first time ([Idempotency](https://fenecdb.com/docs/http#idempotency)) rather than make the row twice. **Offline.** A write made while the server cannot be reached waits in the queue; the page's `online` event, or `setOnline(true)`, sends it at once, the backoff forgotten -- unless the server said when to come back (below) -- and `offline` or `setOnline(false)` closes the streams. Writes left from before go before the streams open again, so what they bring holds them. **A reload.** With `persist` -- or a `local` kept in a file (`openFile`) -- the queue is stored with the replica the moment a write is made, and a page opened again sends what it had not, under the same keys. Without either, a closed page takes its unsent writes with it. **Backoff.** 250 ms, doubling to 15 s, with 30% jitter on top so a server back up is not met by every client at once; a stream that cannot reconnect waits the same way, and starts the count again once it opens. **A tenant's quota.** A tenant node holds each tenant to its quotas ([Quotas](https://fenecdb.com/docs/sharding-reference#quota-bounds)), and names the one that refused a request in its body, which the replica reads. A `507` of `bytes` or `rows` is a refusal: waiting makes no room, deleting does, and a write that only deletes always lands -- held, the write would hold every write behind it for good, the delete that would make its room among them. A `507` naming no quota is the server's own ceiling (`--max-memory`) or a proxy's, and is waited out as a `5xx` is. A `429` of `streams`, `held`, `requests`, `writes` or `write_bytes` is waited out for its `retry_after_ms` -- the wait to the millisecond, a bucket refilling a row in 2 ms where `Retry-After` rounds it up to a second -- or its `retry_after`, the seconds `Retry-After` says, where no milliseconds are given, where that is longer than the backoff, an hour at most, with the jitter on top, since every client the quota refused in one moment is told the same wait; a stream refused so waits the same way, its shape not `connected` meanwhile, and `status()` names the quota (`quota`) beside the error. `setOnline(true)` forgets the backoff but not the server's word: the network says nothing of a tenant's quota. **A move or a failover.** A node answers a write to a tenant it is moving, or no longer holds, `503` with `"retry_after": 1` beside its `Retry-After` ([Moving a tenant](https://fenecdb.com/docs/sharding-tenants#moving-a-tenant)); a standby router whose directory has not arrived answers a stream so too. The write is kept and goes again through the router after that second where it is longer than the backoff, as after a quota's `429`: the node's word for how long a move holds its writes, never the backoff's guess under it. **Put back in order.** A refused write's rows are written back as they stood before it. A later write to the same rows, still waiting, is not replayed on top; the server's answer to it, and the change it sends, has the last word. ## Live queries `db.live(query, fn)` runs the query again whenever a collection it reads changes, and hands `fn` the new rows. Running it again on the local copy takes under a millisecond. A database in the page with no server has the same `live`: see [State in the page](https://fenecdb.com/docs/javascript#state). Moving from one to the other leaves the code that reads unchanged. Why live queries re-run instead of updating There is no incremental maintenance and none is needed. The local side is an indexed database, so re-running the query from scratch is already under a millisecond — the differential dataflow that a `Map`-backed collection requires buys nothing here. It would not even be correct for `near`. A single insert can change the entire top-k ordering, and there is no incremental HNSW maintenance that is both cheap and correct — the machinery would have to be disabled for fenecdb's headline feature. Invalidation is at collection granularity: the change stream says which collections changed, the live queries bound to them re-run, and all of it coalesces into one frame. Anything finer, like intersecting id sets, would cost more than the local query itself. A query built with `db.from` knows the collections it reads, each `lookup`'s included; a FenecQL text names them with `{ collections }`, or runs again after every write. A database in the page alone runs its live queries in a microtask after the task that wrote, not at the next frame: its writes are the page's own, and nothing lands in chunks. Both use the same code. ## Batches `db.batch` makes several writes one: one request, one key. They land on the server together or not at all, and on a refusal the local copy is put back too. ```ts await db.batch(async (t) => { await t.from('tasks').insert({ title: 'a', status: 'open' }); await t.from('tasks').where('key', 'x').update({ status: 'closed' }); }); ``` How a batch lands, and rolls back on both sides The batch is one block on the server: its statements run in order under a single write lock, and their writes land together, as one record, or not at all. The gain is one round trip instead of N, no other writer slipping in between, and nothing half done. **Both sides roll back.** A batch that is refused lands nothing on the server, and the local side puts back exactly what it applied optimistically, the last write first, so the two stay one state. A batch whose answer did not come is kept and sent again, whole, as any write is. ## Multiple tabs share one stream Open tabs elect one leader that holds the connection to the server, passes changes to the others and sends every tab's writes, one queue for all. - **Turn it off** with `leader: false`; without `navigator.locks`, as in Node, it is off already. How tabs elect a leader Every tab has its own wasm instance. If each opened its own subscription there would be N copies and N connections, so a single **leader** is elected with `navigator.locks`; the others receive the same batches over `BroadcastChannel` and apply them to their own local copies. The apply path is identical — only the transport differs. Writes go through the leader, so one queue keeps them in order. A tab applies its own write to its own copy at once, as the leader does, and hands it to the leader, which keeps it in its queue -- stored with its replica when it persists -- sends it under its key and tells every tab the answer; the tab that made the write lands it or puts it back in its copy. When the leader goes, the lock passes to the next tab, which takes over the queue its predecessor stored and is handed every other tab's unanswered writes again; the key makes a write sent twice land once. ## Dropped connections and the horizon A client that reconnects gets only what changed since it left. If it was away too long, the server sends it a fresh copy instead. ```bash --changes entries in the ring (default 4096, 24 bytes per entry) ``` How far behind a client may fall A client returns with `?since=` and asks for no seed. If the cursor has fallen behind the ring, the server reseeds by itself rather than silently sending incomplete events. That number is directly **how far behind a subscriber may fall**. At 100 writes per second, 4096 entries is roughly a 40 second window. With `persist`, the image and the cursor are written to IndexedDB, so a reopened tab is not reseeded from scratch — unless its cursor has fallen past the horizon. A seed writes the shape over the replica and deletes what it does not hold, keeping the rows of writes the server has not answered -- and of those it answered after the seed was taken -- whose copies come later. Written over rather than cleared first, a row that stayed keeps its vector's node. **The server's schema changed.** A change says so (`"schema": true`), or rows come with a field the replica has not got: the replica asks `GET /collections` again, makes the collection again as the server now declares it, its rows kept in the fields that are left, and is sent the whole shape. A schema declared in code is checked against the server's again, and a difference goes to `onError`. When the server restarts, the horizon is set to the counter: a database loaded from a file has no history, only its present state. A cursor standing exactly there, after a quiet restart, gets an empty response; anything behind it is reseeded. ## Cost The server's change ring is about 96 KB at the default size, and subscriptions do not poll. What the change ring costs At the default `--changes 4096` the ring is about 96 KB and costs one push per entry on the write path. Subscriptions do not poll — see [the HTTP endpoint](https://fenecdb.com/docs/http-reference#subscriptions) for why — and are counted separately from ordinary requests under `--http-max-streams`. ## Limits A few things sync does not do, or does differently from what you might expect. - **A shape is not a security boundary** on its own; use a per-user token with scoped access. - **`bytes` fields cannot be synced.** - **The server has the last word** over the local copy. - **A write that picks its rows** — `order` and `limit` before an `update` or a `delete`, or `{ returning }`, a [job queue's claim](https://fenecdb.com/docs/queues) — is refused on a synced collection: the replica would pick and answer its own rows, and the server its own. Send it to the server itself (`db.remote`). What a shape does not guarantee - **A shape alone is not a security boundary.** Under the server's own token, a changed id that does not match the shape is sent as a deletion: harmless locally, but which ids changed leaks. A per-user token with [scoped access](https://fenecdb.com/docs/http-access#scoped-access) hides both: the rows outside its rules are filtered out, and it hears only of rows it was sent. - **`bytes` fields cannot be synced.** In JSON they become number arrays with no way back. That is a limit of the HTTP surface, not something sync introduces. - **In a projected shape the other fields read as `null`.** With `select`, the local row carries only those fields — which is all the client knows anyway. - **`batch()` does not nest** and the calls inside it return no result: writes pile up and go in a single request. - **Rolling back needs no network, but the server has the last word.** A rolled-back local state is overwritten by the server's in the next batch. - **Rows a `@ttl` lets go of** leave a replica's reads at once -- the replica's collection has the server's `@ttl` -- and arrive as deletes when the server's sweep takes them. ## Persisting the replica Add `persist` to keep the copy, its place in the stream and the writes the server has not answered in IndexedDB, so a reopened tab starts from there instead of downloading everything again, and sends what it had not. ```ts import { sync } from './fenec.js'; import type { FenecSchema } from './fenec-schema.js'; const db = await sync({ url: 'https://api.example.com', shapes: [{ collection: 'tasks', where: { status: 'open' }, key: 'key' }], persist: 'tasks', // the replica, its cursors and its unsent writes }); await db.ready(); db.live(db.from('tasks').order('priority', 'desc'), draw); await db.from('tasks').insert({ title: 'new', status: 'open' }); ``` --- Source: https://fenecdb.com/docs/redis # Instead of Redis The jobs an app adds Redis for — counters, rate limits, locks, sessions, queues — done by the database it already has, each a statement or two. The job most apps add Redis for first is a cache in front of the database. `fenec-server` usually does not need one: the file is mapped, the operating system keeps the pages in use in memory, and a read decodes the row from them. Measured against Redis 7.4 on YCSB in Docker, the same durability mode against the same, Redis does more operations a second in none of the 33 cells measured both ways ([the figures and their conditions](#cache-in-front)). Four things make these work. A `set` can read the row it writes (`{n: n + 1}`), and the read and the write happen under the one writer's lock, so an increment is atomic. Every write answers how many rows it changed, so a write that names what it expects says whether it found it, and `require` makes that count a condition of its `/batch`. `@ttl` lets a row expire, and a write take its place the moment it does. And every write can be watched: a subscription is sent the rows of a shape as they change, and `/_changes` every write on disk. See Writes that read the row for the rules. Every FenecQL block on this page is run, statement by statement, by a test (`crates/fenec-core/tests/redis_docs.rs`), and each `-- → x` is the answer it checks: a write's count, or the first value of the first row a read answered. A line `-- 20 s later` moves the test's clock, and `$1` is the rows the read before it answered, as a client sends them back. The costs are `make counters-bench` on an M1 Air, in process unless said otherwise; over HTTP a request adds its round trip. Where another bench measured a recipe, it is named. ## A cache in front of fenec-server fenecdb has no buffer pool of its own: the file is mapped, the operating system keeps the pages in use in memory, and a read decodes the row straight from them ([How it works](https://fenecdb.com/docs/concepts#no-buffer-pool)). A row read often is in memory with nothing to warm, size or keep in step, so a separate cache in front of `fenec-server` is usually unnecessary while the working set fits in memory. YCSB's workloads against Redis 7.4, in Docker, each durability mode against the same mode of the other: | 1 000 000 records of 1 KB | fenec-server | Redis 7.4 | | C: 100% read, 16 clients, buffered | **18.4 k** p99 2.9 ms | 14.0 k p99 4.0 ms | | B: 95% read, 5% update, 16 clients, durable | **18.2 k** p99 6.0 ms | 9.25 k p99 8.8 ms | Operations a second, and under each the p99 of the workload's slowest operation, as the benchmark page gives them. Redis does more operations a second than fenec-server in none of the 33 cells measured both ways: workloads A to F at 1, 4 and 16 clients, durable and buffered (C, which writes nothing, buffered alone). Nor does Redis with its snapshots alone, no append-only file, which keeps no log: it is behind fenec-server buffered in none of the 15 cells measured that way (A, B, D, E and F at 1, 4 and 16 clients). [Every cell](https://fenecdb.com/docs/benchmarks-ycsb#ycsb-redis), and the method. Over the same network a cache in front can take off at most the time a read spends inside the server, and a hop to it is no shorter than a hop to the database. 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 ([taken apart](https://fenecdb.com/docs/benchmarks-requests#round-trip)); one client's read of a record takes 0.29 ms at the median from fenec-server (C, buffered) and 0.32 ms from Redis. What a cache adds is an invalidation to keep right: a write must reach it, or a read is answered from before it. The conditions: - **While the working set fits in memory.** The system keeps the pages in use as far as it has room for them; past that, a read waits for the disk, as in any database. The server compacts a file before it outgrows what the system has for it ([Compaction](https://fenecdb.com/docs/server#compaction), before the file outgrows the page cache). - **One node, Docker's network, YCSB's records.** fenec-server and Redis are each a container, on one machine, and a record is ten fields of 100 characters. A cache nearer the app than the database is, in the app's own process or on its host while the database is across a network, takes the network off a read: this measures two servers on one network, and says nothing of a nearer one. - **The database's reads.** It is not about an HTTP or CDN response cache, which saves a request reaching the server at all ([responses are not marked cacheable](https://fenecdb.com/docs/limits#fenec-http)), nor a cache that must stay inside a memory budget by dropping what is used least, which is Redis's (below). - **Embedded, and in the browser,** a read is a call in the app's own process, with no hop to put a cache in front of ([Embedded Rust](https://fenecdb.com/docs/embedding), [JavaScript](https://fenecdb.com/docs/javascript)). ## Redis's patterns, and where each one is | Pattern | Redis | Here | | Counters | `INCR`, `INCRBY`, `HINCRBY` | [`set c {n: n + 1}`](#counter) | | Rate limit, fixed window | `INCR` and `EXPIRE` | [a row a window, `@ttl`](#fixed-window) | | Rate limit, sliding window | `ZADD`, `ZREMRANGEBYSCORE`, `ZCARD` in `MULTI` | [a row a request, `@ttl`, a read that must find none](#sliding-window) | | Rate limit, token bucket | a Lua script | [one `set` over the bucket's row](#token-bucket) | | Lock with a lease | `SET NX PX`, a check-and-delete script | [`put … if absent`, `@ttl`, `del … where owner`](#lock) | | Fencing token | none of its own: `INCR` beside the lock | [counted by the write that takes the lock](#fencing) | | Optimistic transaction | `WATCH`, `MULTI`, `EXEC` | [a version, and `require 1` in a `/batch`](#optimistic) | | Sessions | `SET EX`, `GET`, `GETEX`, `EXPIRE` | [`@ttl`, `set … returning`](#sessions) | | Cache with a TTL | `SET EX`, `GET`, `DEL` | [`@ttl` and an upsert — no eviction](#cache) | | Leaderboard | `ZADD`, `ZINCRBY`, `ZREVRANGE`, `ZRANGEBYSCORE`, `ZREVRANK` | [`@sorted`, `order … limit`, `count`](#leaderboard) | | Sets, deduplication | `SADD`, `SISMEMBER`, `SREM`, `SCARD`, `SINTER` | [`@unique` and `if absent`](#sets) | | Unique visitors | `PFADD`, `PFCOUNT`, `PFMERGE` | [`approx_count_distinct`, sketches kept as bytes](#unique-visitors) | | Job queue | `LPUSH`, `BRPOP`, `BLMOVE` | [a claim: `set … order … limit … returning`, `Fenec-Wait`](#queue) | | Delayed jobs | `ZADD` by time, `ZRANGEBYSCORE` polled | [a `run_at` ahead, the claim woken at it](#delayed) | | Streams, consumer groups | `XADD`, `XRANGE`, `XREADGROUP`, `XACK` | [a collection, `/_changes` and its consumers](#streams) | | Pub/sub, keyspace notifications | `PUBLISH`, `SUBSCRIBE`, `notify-keyspace-events` | [a row with a short `@ttl`, a subscription](#notification) | | Idempotency keys | `SET NX EX` on a request's id | [`Idempotency-Key`, or `@unique` and `if absent`](#idempotency) | | Places near a point | `GEOADD`, `GEOSEARCH`, `GEODIST` | [a `geo` field, `@geo`](#geo) ([Points and distances](https://fenecdb.com/docs/geo)) | | Counts by the minute | `INCR` on a key a minute | [`bucket(at, 1m)` over the events](https://fenecdb.com/docs/analytics#rollups) ([Analytics](https://fenecdb.com/docs/analytics)) | | A cache that evicts under memory pressure | `maxmemory`, `allkeys-lru`, `allkeys-lfu` | [not covered](#not-covered): a row lives until its `@ttl` | | Scripts | `EVAL`, `FUNCTION` | [not covered](#not-covered): a `/batch` with `require` | | Bitmaps, Bloom filters, Top-K | `SETBIT`, `BITCOUNT`, `BF.ADD`, `TOPK.ADD` | [not covered](#not-covered) | What every recipe can count on: - **Atomic.** There is one writer: a statement finds its rows and writes them under the write lock, and a `/batch` is one block — every statement in it lands, or none does, with no other write between them. - **Durable by `--sync`.** Under `--sync always` a write is answered once it is on disk; under the default, `250`, a crash loses at most the last 250 ms of writes ([Durability](https://fenecdb.com/docs/server#durability)). - **On a replica.** A replica is sent each write as the primary wrote it — a counter's new value, never `n + 1` — once it is on the primary's disk, in the primary's order. It refuses writes (403), so increments, locks and claims go to the primary; a read sent there with `Fenec-After` waits for the write it names ([Replication](https://fenecdb.com/docs/replication)). A row past its `@ttl` is out of a replica's reads as it is out of the primary's, and deleted when the primary's sweep is sent. ## Counter An increment is a `set` that reads the field it writes. ```fenecql create collection counters (name text @unique, n int) put counters {name: "visits", n: 0} set counters {n: n + 1} where name = "visits" -- → 1 get counters select n where name = "visits" -- → 1 set counters {n: n + 5} where name = "visits" returning n -- → 6: INCRBY's answer put counters {name: "signups", n: 1} if absent else set {n: n + 1} -- → 1: made at 1 put counters {name: "signups", n: 1} if absent else set {n: n + 1} -- → 1: added to get counters select n where name = "signups" -- → 2 ``` - **None is lost.** Sixteen HTTP clients each incrementing one key 10 000 times ended at 160 000, 102 000 increments a second against `fenec-server`; sixteen threads in process, 320 000 a second. - **An increment costs** 1.36 us in process. - **From nothing:** `coalesce(n, 0) + 1` counts a row with no `n` yet; `n + 1` over a `null` is `null`. A key that may not be there yet is made or added to by one statement, an upsert: `if absent else set`. - **`returning`** answers the row as written, so the new value comes back with no second read. In JavaScript, `inc()` writes the `coalesce` for you: ```ts import { connect, inc } from '@fenecdb/web/client'; const db = connect('http://127.0.0.1:8080'); await db.from('counters').where('name', 'visits').update({ n: inc(1) }); ``` Python's `inc(1)`, Go's `fenecdb.Inc(1)`, .NET's `Computed.Inc(1)`, and Swift's, Kotlin's and Dart's builders render the same text. ## Rate limits Three ways, each decided by what a write finds: a fixed window, the cheapest; a sliding window, exact to the moment; a token bucket, which lets a burst through up to its size and then a steady rate. ### Fixed window A row a client and window, made if absent and expiring after the window; the increment names the limit, so its answer is the decision. ```fenecql create collection hits (key text @unique, n int, at timestamp @ttl(2m)) put hits {key: "ip1:29630000", n: 0, at: now()} if absent -- → 1: the window's row put hits {key: "ip1:29630000", n: 0, at: now()} if absent -- → 0: there already set hits {n: n + 1} where key = "ip1:29630000" and n < 2 -- → 1: allowed set hits {n: n + 1} where key = "ip1:29630000" and n < 2 -- → 1: allowed set hits {n: n + 1} where key = "ip1:29630000" and n < 2 -- → 0: over the limit ``` - **The window is in the key** — here the minute, `floor(now / 60000)`, which the client works out — so a new window is a new row, and `@ttl` lets the old ones go. - **A request costs** its two statements, 3.5 us; send them as one [`/batch`](https://fenecdb.com/docs/http#batches) for one round trip. - **A fixed window lets a burst through at its edge:** the limit at the end of one minute and again at the start of the next. The two below do not. ### Sliding window A row a request, expiring after the window, and a read that must find fewer than the limit: `offset 2 limit 1 require 0` asks that no third row is there, so it allows three a minute. Sent as one `/batch` with the request's row, the read refuses the batch (412) once three are, and the row is not written. ```fenecql create collection calls (key text @hash, at timestamp @ttl(1m)) get calls where key = "ip1" offset 2 limit 1 require 0 -- → no row: allowed put calls {key: "ip1", at: now()} -- → 1 get calls where key = "ip1" offset 2 limit 1 require 0 -- → no row: allowed put calls {key: "ip1", at: now()} -- → 1 -- 30 s later get calls where key = "ip1" offset 2 limit 1 require 0 -- → no row: allowed put calls {key: "ip1", at: now()} -- → 1 get calls where key = "ip1" offset 2 limit 1 require 0 -- → refused: three in the last minute -- 30 s later get calls where key = "ip1" offset 2 limit 1 require 0 -- → no row: the first two are a minute old ``` - **Exact:** the rows of the last minute, counted the moment the request comes, since a row past its `@ttl` is out of the read at once. The guard and the write are one block under the write lock, so two requests cannot both take the last place. - **A row a request** is what it keeps, as Redis's sorted set keeps a member a request; a limit of thousands a window is a fixed window or a bucket instead. ### Token bucket A bucket holds up to two tokens and gains one a second; a request takes one. The row keeps the tokens and when they were counted, and one `set` works out what the time since has added — the two timestamps apart are milliseconds — and takes a token only where there is one: ```fenecql create collection buckets (key text @unique, tokens float, at timestamp @ttl(1h)) put buckets {key: "ip1", tokens: 2.0, at: now()} if absent -- → 1: a full bucket set buckets {tokens: least(2.0, tokens + (now() - at) / 1000.0) - 1, at: now()} where key = "ip1" and tokens + (now() - at) / 1000.0 >= 1 -- → 1: allowed set buckets {tokens: least(2.0, tokens + (now() - at) / 1000.0) - 1, at: now()} where key = "ip1" and tokens + (now() - at) / 1000.0 >= 1 -- → 1: allowed, empty now set buckets {tokens: least(2.0, tokens + (now() - at) / 1000.0) - 1, at: now()} where key = "ip1" and tokens + (now() - at) / 1000.0 >= 1 -- → 0: refused -- 1 s later set buckets {tokens: least(2.0, tokens + (now() - at) / 1000.0) - 1, at: now()} where key = "ip1" and tokens + (now() - at) / 1000.0 >= 1 -- → 1: one token back ``` - **The script Redis needs** for this is the one statement: the row is read and written under the write lock, so no two requests take the same token. - **A refused request writes nothing,** so the time it waited still counts toward the next token. - **The `@ttl` is longer than a refill:** a bucket idle for an hour would be full again, so its row may go, and the next request makes it full. From a client, the two statements go as one `/batch`, with `require 1` on the take so that a refusal is a 412: ```ts import { connect, FenecError } from '@fenecdb/web/client'; const db = connect('http://127.0.0.1:8080'); // true: serve the request; false: answer it 429. async function allow(key: string): Promise { try { await db.batch([ ['put buckets {key: $1, tokens: 2.0, at: now()} if absent', [key]], ['set buckets {tokens: least(2.0, tokens + (now() - at) / 1000.0) - 1, at: now()} ' + 'where key = $1 and tokens + (now() - at) / 1000.0 >= 1 require 1', [key]], ]); return true; } catch (e) { if (e instanceof FenecError && e.status === 412) return false; throw e; } } ``` ## Lock with expiry A lock is a row: taken by the write that makes it, held while it lives, and free again once its time passes, so a holder that dies cannot keep it. ```fenecql create collection locks (name text @unique, owner text, at timestamp @ttl(30s)) put locks {name: "nightly", owner: "w1", at: now()} if absent -- → 1: w1 holds it put locks {name: "nightly", owner: "w2", at: now()} if absent -- → 0: w2 does not set locks {at: now()} where name = "nightly" and owner = "w1" -- → 1: renewed set locks {at: now()} where name = "nightly" and owner = "w2" -- → 0: not w2's del locks where name = "nightly" and owner = "w1" -- → 1: released ``` - **Exactly one wins.** The write lock decides: of any number of clients racing, one is answered 1 and the rest 0 — sixteen threads are raced for it in `crates/fenec-core/tests/writes.rs`, and again once its time has passed. - **Thirty seconds after its last renewal** the row is out of every read, the renewal's answer is 0 — the holder has lost it — and the next `put … if absent` takes it, the old row deleted in the same statement. The sweeper need not have come by. - **The check-and-delete is the `del`:** it names the owner, so a holder whose lock lapsed and was taken by another deletes nothing — the script Redis's documentation gives for the release. - **Costs:** refused while held 0.91 us, renewed 2.34, released and taken again 2.67. How if absent and the ttl behave as a lock `if absent` passes over a document whose `@unique` value or `id` a live row holds, and counts what it wrote: Redis's `SET NX`. `insert` refuses the same document with a `409` instead, which serves too, but makes losing the race an error to catch and aborts a `/batch` it is part of. A lock named by a number can be its `id`: `put locks {id: 1, owner: $1, at: now()} if absent`. Time is the server's clock, and a lock lasts its ttl from its last renewal: renew well within it. As with any lock that expires, a holder paused past its ttl may still believe it holds the lock; give the work it guards a check of its own — a fencing token, below — where that matters. ### Fencing tokens A holder paused past its lease — a long garbage collection, a stalled disk — wakes believing it holds the lock, while another does. A fencing token tells the two apart: a number the lock hands out, one more at every acquisition, which the work it guards carries and the resource checks. Here the lock's row keeps it, so the lock is a row that stays, and the lease is its `until`: ```fenecql create collection leases (name text @unique, owner text, until timestamp, fence int) put leases {name: "report", fence: 0} if absent -- → 1: the lock's row, made once set leases {owner: "w1", until: now() + 30000, fence: fence + 1} where name = "report" and (owner is null or until <= now()) returning fence -- → 1: w1 holds it, token 1 set leases {owner: "w2", until: now() + 30000, fence: fence + 1} where name = "report" and (owner is null or until <= now()) returning fence -- → no row: held -- 40 s later set leases {owner: "w2", until: now() + 30000, fence: fence + 1} where name = "report" and (owner is null or until <= now()) returning fence -- → 2: w1's lease lapsed set leases {until: now() + 30000} where name = "report" and owner = "w2" and fence = 2 and until > now() require 1 -- → 1: renewed ``` The resource keeps the highest token it has been written with, and takes a write only from that token or a newer one: ```fenecql create collection reports (day text @unique, body text, fence int) put reports {day: "2026-05-03", fence: 0} if absent -- → 1 set reports {body: "by w2", fence: 2} where day = "2026-05-03" and fence <= 2 require 1 -- → 1: token 2 is the newest set reports {body: "by w1", fence: 1} where day = "2026-05-03" and fence <= 1 require 1 -- → refused: w1 woke late, with token 1 set leases {owner: null, until: null} where name = "report" and owner = "w2" and fence = 2 require 1 -- → 1: released ``` - **The token is counted by the write that takes the lock,** under the write lock: no two acquisitions get one number, and none is skipped. Redis's `SET NX` hands out none: a counter beside the lock is a second key, kept in step with it by a script. - **The row stays** so the count does: under `@ttl` it would be deleted when it lapsed, its count with it. A lapsed lease is `until <= now()`, which the next acquisition reads. - **Waiting for it:** sent over HTTP with `Fenec-Wait`, an acquisition that takes nothing is held at the server until the holder releases it or its lease lapses, whichever comes first — both are a write or a time the server knows of (Holding a write until it can write). - **A resource in fenecdb** checks the token in the write itself, as above. One elsewhere — a file store, another service — needs a check of its own; the token is a number it can keep. ## Optimistic concurrency Read a row with its version; write it back only where the version is still the one read, and move the version on in the same statement — what Redis's `WATCH` and `MULTI` are for. ```fenecql create collection docs (body text, version int) put docs {id: 7, body: "draft", version: 1} get docs select body, version where id = 7 -- → "draft" set docs {body: "final", version: version + 1} where id = 7 and version = 1 -- → 1: written set docs {body: "other", version: version + 1} where id = 7 and version = 1 -- → 0: read again set docs {body: "other", version: version + 1} where id = 7 and version = 1 require 1 -- → refused: 412, and its batch put back ``` - **0 means someone wrote first:** read the row again and retry, or tell the user. - **In a `/batch`, add `require 1`:** a 0 would let the statements after it go on, where a lost race should stop them. With it the write that found another version is refused (412) and the batch is put back whole ([Writes that must find their rows](https://fenecdb.com/docs/fenecql-writing#require)). A `get … require 1` guards a batch on a value it does not write (A read that must find its rows). - **Costs:** a write that wins 1.13 us, one that loses 0.66. ## Session store A session is a row that lives while it is used: `@ttl` on the time it was last seen, a `@unique` token to find it by. ```fenecql create collection sessions (token text @unique, user text, data json, seen timestamp @ttl(30m)) put sessions {token: "s-81f2", user: "ada", data: {"cart": 2}, seen: now()} get sessions select user where token = "s-81f2" -- → "ada" set sessions {seen: now()} where token = "s-81f2" -- → 1: seen again set sessions {seen: now()} where token = "s-81f2" returning user, data.cart -- → "ada": read and seen again, GETEX del sessions where token = "s-81f2" -- → 1: signed out ``` - **An idle session is gone at once** for every read, thirty minutes after it was last seen, and the server's sweeper deletes it one to two minutes later ([Rows that expire](https://fenecdb.com/docs/fenecql-indexes#ttl)). - **Read and renewed in one** by `set … returning`: the row as written comes back, and a session already gone answers no row. - **Costs,** over 10 000 sessions: a read 1.32 us, seen again 2.26. ## Cache with expiry A value kept for a while in front of something slow to work out: read it by its key, and on a miss work it out and keep it. The upsert writes a key that is there and one that is not alike, and starts its time again. ```fenecql create collection cache (key text @unique, value json, at timestamp @ttl(5m)) get cache select value.name where key = "user:7" -- → no row: a miss put cache {key: "user:7", value: {"name": "Ada"}, at: now()} if absent else set {value: new.value, at: now()} -- → 1: kept for five minutes get cache select value.name where key = "user:7" -- → "Ada": a hit put cache {key: "user:7", value: {"name": "Ada L."}, at: now()} if absent else set {value: new.value, at: now()} -- → 1: written over del cache where key = "user:7" -- → 1: invalidated put cache {key: "user:7", value: {"name": "Ada"}, at: now()} if absent else set {value: new.value, at: now()} -- → 1 -- 300 s later get cache select value.name where key = "user:7" -- → no row: five minutes old ``` What it is, and what it is not: - **No eviction.** There is no LRU or LFU and no `maxmemory` policy: a row lives until its `@ttl` or a `del`, however rarely it is read. What the cache holds is what was written within its ttl. With `--max-memory`, a write past the ceiling is refused (507) rather than make room by dropping another row (Limits). A cache that must stay inside a memory budget by dropping what is used least wants Redis. - **It survives a restart.** The rows are in the file, durable by `--sync`, so a server started again answers from a warm cache, and a replica holds it too. - **The values are in the file, not the heap.** The file is mapped: the process holds the indexes derived from the rows, and the rows themselves are pages the system keeps or lets go — a 1 GB file of 2.3 million rows with two indexes held 188 MB (The file is mapped). - **Expired rows go in the background.** Out of every read at once, deleted by the server's sweeper one to two minutes later, 1 000 a block: 100 000 of 200 000 rows went in 0.56 s, each block holding the write lock 1.06 ms at the median and 2.3 at most (`make ttl-bench`). The space an overwrite leaves is taken back by the server's compaction once half the file is dead (Compaction). - **Many clients missing one key** at once each work it out; to have one do it, the first takes the key's [lock](#lock) and the rest read again after. ## Leaderboard A score field with `@sorted`: the top of the board is a walk of the index from its end, and a player's rank is a count. ```fenecql create collection scores (player text @unique, score int @sorted) put scores [{player: "ada", score: 0}, {player: "bo", score: 90}, {player: "cy", score: 40}] set scores {score: score + 120} where player = "ada" -- → 1: ZINCRBY get scores select player, score order score desc limit 10 -- → "ada": ZREVRANGE 0 9 get scores select player, score order score desc limit 10 offset 1 -- → "bo": the next page get scores select score where player = "ada" -- → 120: ZSCORE get scores where score > 120 count -- → 0: ada is first, ZREVRANK get scores select player where score >= 40 and score <= 100 order score desc -- → "bo": ZRANGEBYSCORE ``` - **A rank is a count** of the scores above the player's: read the score, then count, two statements — or one, where the score is known. - **Ties** come out in the order the players were first written, their ids', whichever way the board is read; Redis orders them by the members' names. - **Costs,** over 100 000 players: a score added 1.79 us, the top ten 1.52. A player's rank is a count of the scores above it, the index's range gathered: 1.5 ms at the median of the board. ## Sets and deduplication A member is a row, and `@unique` over the set and the member keeps it there once: `if absent` adds it and answers whether it was new. ```fenecql create collection members (list text @hash, member text, @unique(list, member)) put members {list: "tags:7", member: "rust"} if absent -- → 1: SADD, new put members {list: "tags:7", member: "rust"} if absent -- → 0: there already put members [{list: "tags:7", member: "db"}, {list: "tags:8", member: "rust"}] if absent -- → 2 get members where list = "tags:7" and member = "rust" count -- → 1: SISMEMBER get members where list = "tags:7" count -- → 2: SCARD get members select member where list = "tags:7" and member in (get members select member where list = "tags:8") -- → "rust": SINTER del members where list = "tags:7" and member = "db" -- → 1: SREM ``` - **A set's members** are its `@hash` bucket, read without the others: `SMEMBERS` is `get members select member where list = $1`. - **Seen-it sets** — message ids, a feed's items already shown — are a collection of one `@unique` key and a `@ttl`, so an id is remembered for as long as a repeat can come. - **Exact,** at a row a member: a Bloom filter's fixed memory and its false positives are [not here](#not-covered). ## Unique visitors `approx_count_distinct` is HyperLogLog with 214 registers — Redis's register count, so its standard error, 0.81% — counted over the visits as they were written. `count(distinct)` is the exact count, up to a million values a query. ```fenecql create collection visits (user text, page text @hash, at timestamp @sorted) put visits [{user: "ada", page: "/", at: "2026-05-03T09:00:00Z"}, {user: "bo", page: "/", at: "2026-05-03T09:05:00Z"}, {user: "ada", page: "/docs", at: "2026-05-03T10:00:00Z"}, {user: "cy", page: "/", at: "2026-05-04T08:00:00Z"}] -- → 4: PFADD, a row a visit get visits select approx_count_distinct(user) as users -- → 3: PFCOUNT get visits select approx_count_distinct(user) as users, bucket(at, 1d) as day group day -- → 2: on the 3rd get visits select count(distinct user) as users where page = "/" -- → 3: exact ``` A day's sketch kept in a row is Redis's key a day: read out as bytes with `hll_accumulate`, put back as a client sends rows, and merged — `PFMERGE` — by `hll_combine`, each visitor of two days counted once: ```fenecql create collection daily (day timestamp @unique, users bytes) get visits select bucket(at, 1d) as day, hll_accumulate(user) as users group day -- → "2026-05-03T00:00:00Z" put daily $1 -- → 2: the rows above get daily select hll_estimate(hll_combine(users)) as users -- → 3: both days, ada once get daily select hll_estimate(users) as users where day = "2026-05-03" -- → 2 ``` - **Costs:** per hour over a million events, 121 ms against `count(distinct)`'s 128, the furthest of the 169 hours 1.3% off (`make analytics-bench`). - **Sketches are the same bytes** wherever they are made, the browser's or a server's, and a group of few values keeps few words rather than 16 KB ([Distinct users](https://fenecdb.com/docs/analytics#distinct)). ## Job queue A job is a row. A worker claims a batch with one statement, which finds the oldest ready jobs and makes each its own for a lease, and acks a job by deleting it. ```fenecql create collection jobs (payload json, run_at timestamp @sorted, owner text, attempts int) put jobs [{payload: {"to": "ada"}, run_at: now(), attempts: 0}, {payload: {"to": "bo"}, run_at: now(), attempts: 0}] set jobs {owner: "w1", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 10 returning id -- → 1: both, w1's for 30 s del jobs where id = 1 and owner = "w1" require 1 -- → 1: done ``` - **None is taken twice:** the claim finds its jobs and writes them under the write lock. A job whose worker died is ready again once its lease lapses, and the next claim takes it — at least once, where `BRPOP` loses a job its worker dies holding. - **Costs,** over a million jobs: a claim of ten 18.7 us; sixteen threads claimed and acked them all in 5.4 s, 185 800 jobs a second, and sixteen HTTP clients at 138 500 (`make queue-bench`). - **`BLPOP`:** sent over HTTP with `Fenec-Wait`, a claim that finds no job is held at the server until one is enqueued, a delayed one comes due or a lease lapses, and handed to the worker that has waited longest: a job enqueued reached a waiting worker in 0.38 ms at the median (Waiting for a job). - **Retries with a backoff, dead letters, delays and priorities** are on their own page, [Job queues](https://fenecdb.com/docs/queues). ### Delayed jobs A job delayed is one whose `run_at` is ahead: no claim takes it before then, and the claim that would take it is the same one. ```fenecql create collection reminders (payload json, run_at timestamp @sorted, owner text, attempts int) put reminders {payload: {"to": "ada"}, run_at: now() + 60000, attempts: 0} -- → 1: due in a minute set reminders {owner: "w1", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 10 returning id -- → no row: not yet -- 60 s later set reminders {owner: "w1", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 10 returning id -- → 1: due ``` - **No poll:** a claim held with `Fenec-Wait` is woken at the moment the next job comes due, which the server works out from the `@sorted` index: 0.04 ms after its time at the median and 1.9 at p99, against 1.2 and 8.4 polling every 10 ms (Waiting for a job). - **No ceiling** on the delay, and a delayed job is read like any row: `get reminders where run_at > now() count`. ## Streams A stream is a collection written to and never written over: each entry a row, its `id` counted up as it is added. A range is a walk of the ids from the last one read, and old entries go by `@ttl`. ```fenecql create collection activity (kind text @hash, user text, at timestamp @ttl(7d)) put activity {kind: "signup", user: "ada", at: now()} -- → 1: XADD put activity [{kind: "order", user: "ada", at: now()}, {kind: "order", user: "bo", at: now()}] -- → 2 get activity select id, kind where id > 1 limit 100 -- → 2: XRANGE, after entry 1 get activity where kind = "order" count -- → 2 ``` A reader that must see every write once, and pick up where it left off, reads the server's change stream: every write on disk, a JSON line each. Started with `--cdc`, a named consumer keeps its place on the server, as a consumer group's last delivered id does: ```bash fenec-server --file data.fenec --http 127.0.0.1:8080 --cdc curl -X POST http://127.0.0.1:8080/_changes/consumers/mailer # XGROUP CREATE … $ curl -i 'http://127.0.0.1:8080/_changes?consumer=mailer&wait=10000' # XREADGROUP … BLOCK 10000 # Fenec-Next: 42 -- handle the lines, then XACK up to there # (an earlier since goes back, as XGROUP SETID does): curl -X PATCH http://127.0.0.1:8080/_changes/consumers/mailer -d '{"since": 42}' ``` Unlike `XGROUP CREATE`, creating a consumer that already exists is no error (no `BUSYGROUP`): it answers 200 and stays where it is, so a worker can create its consumer every time it starts. A `POST` with a `since` ahead of where it stands is 409: moving it is the `PATCH`'s. - **At least once** where the server keeps the place: a consumer that crashes between reading and committing is handed those writes again ([Consumers](https://fenecdb.com/docs/http-reference#consumers)). One that keeps `Fenec-Next` beside its own state, committed with it, has each write once ([Change data capture](https://fenecdb.com/docs/http-reference#changes)). - **Only what is on disk:** a write is handed out once an fsync has covered it, so none a crash lost reaches a consumer. - **Every collection's writes** are in it, each line naming its collection, puts, deletes and schema changes alike; a consumer passes over the rest. - **One consumer, every write.** A consumer group whose consumers share the entries — each to one of them, the pending ones claimed again after a while — is a [job queue](#queue): a claim and its lease. - **How far back:** as many writes as `--replication-buffer` holds, 64 MiB unless given; a cursor further back is answered 410 with the first `since` it does serve, never with writes missing from the middle. ## Pub/sub and change notification A message is a row, kept for as long as a late subscriber should still see it, and a subscription is sent the rows of a shape — first those there are, then each change as it lands. ```fenecql create collection messages (room text @hash, body text, at timestamp @ttl(1m)) put messages {room: "lobby", body: "hello", at: now()} -- → 1: PUBLISH ``` ```bash # SUBSCRIBE: the rows of a shape, then each change to them as it lands curl -N 'http://127.0.0.1:8080/messages/changes?room=eq.lobby' # every write on disk, from where a named consumer left off curl 'http://127.0.0.1:8080/_changes?consumer=mailer&wait=10000' ``` ```ts import { connect } from '@fenecdb/web/client'; const db = connect('http://127.0.0.1:8080'); const stop = db.subscribe('messages', { room: 'eq.lobby' }, (ev) => { if (ev.type === 'change') for (const m of ev.puts) console.log(m.body); }); await db.run('put messages {room: $1, body: $2, at: now()}', ['lobby', 'hi']); stop(); ``` - **A subscription** ([Subscriptions](https://fenecdb.com/docs/http-reference#subscriptions)) is woken by the write, not by polling: from a write sent to its event read by a subscriber took 0.037 ms at the median and 0.103 at p99 over HTTP on one machine. - **A late subscriber** is sent the messages still inside their `@ttl` first, where Redis's pub/sub hands it only what is published after it subscribed. - **Fan-out costs the writer.** Every write wakes every stream, which reads what it changed: a write's p50 went from 0.26 ms with no stream to 0.94 with 60 and 3.1 with 500, each stream a connection and a thread, 64 at most unless `--http-max-streams` says otherwise. A page thousands view polls instead (Polling a read). - **A live query** keeps a query's answer rather than a shape's rows: `db.live(query, cb)` runs it again on the server after every write to what it reads (JavaScript). - **The change stream** ([Change data capture](https://fenecdb.com/docs/http-reference#changes)) hands out every write on disk exactly once to a consumer that sends back where it is, which a subscription — a shape's rows kept up to date — does not promise: `notify-keyspace-events` with a cursor. ## Idempotency keys A client that sends a write again after a timeout, not knowing whether the first landed, sends it with the same `Idempotency-Key`: the server keeps the key and the answer in the same block as the write, and a repeat is handed the first answer. ```bash 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]}' # sent again with the key: the first answer, Idempotent-Replayed: true, nothing written ``` - **The key and the write land together,** so a crash cannot leave one without the other; a failed write keeps no key, and the key with another request is refused (422). Keys are kept a day unless `--idempotency-ttl` says otherwise (Retrying a write). - **Costs:** 16 200 single puts a second over HTTP with a key, against 18 300 without. A message that arrives more than once — a webhook redelivered, an event replayed — is told apart by its own id: a row of it, made if absent, and the work done only where it was. ```fenecql create collection deliveries (event text @unique, at timestamp @ttl(1d)) put deliveries {event: "evt_1J8", at: now()} if absent -- → 1: first time, handle it put deliveries {event: "evt_1J8", at: now()} if absent -- → 0: a repeat, already handled put deliveries {event: "evt_1J8", at: now()} if absent require 1 -- → refused: in a /batch, its work put back with it ``` - **With `require 1` in a `/batch`** beside the writes it guards, the mark and the work land together or not at all: a repeat is refused (412) and writes nothing, and a crash cannot leave work done with no mark. ## Places near a point `GEOADD` and `GEOSEARCH` are a `geo` field with `@geo`: the metres between two points, the rows within a radius or a box, the nearest first — see [Points and distances](https://fenecdb.com/docs/geo). ```fenecql create collection sicily (name text, loc geo @geo) put sicily [{name: "Palermo", loc: [13.361389, 38.115556]}, {name: "Catania", loc: [15.087269, 37.502669]}] -- → 2 get sicily select name where distance(loc, [15, 37]) <= 200000 near loc [15, 37] limit 10 -- → "Catania" ``` - **Costs,** over a million points: every point within 1 km 12.6 us, against 34.3 us for Redis's `GEOSEARCH`, and the ten nearest 33 us, against 198 ms — the server's own time for Redis, the network left out (`make geo-bench`, What it costs). ## What fenecdb does not do - **Evict.** No LRU or LFU, no `maxmemory` policy: a row lives until its `@ttl` or a `del`, and `--max-memory` refuses a write past it (507) rather than drop another row ([Cache with expiry](#cache)). A cache that must stay inside a memory budget by forgetting what is used least wants Redis. - **Keep its data in memory alone.** A server always keeps a file, and a write is as durable as `--sync` says: the default, `250`, answers before the fsync and loses at most the last 250 ms in a crash; `always` answers after it, a durable update 4.0 ms at the median on a Mac (`F_FULLFSYNC`) and 0.325 ms on Linux in Docker (One request's round trip); `off` syncs at shutdown alone (Durability). A request is HTTP: a read by id took 0.022 ms at the median on one machine (A request over HTTP), and a network's round trip adds to it. Memory alone, a function call a statement, is the engine embedded in the app — in Rust, where an increment costs 1.36 us, or in the page (Embedded Rust, JavaScript). - **Run scripts.** No Lua, no `EVAL` or `FUNCTION`. What a script checks before it writes is `require` on the statements of a `/batch`, which lands whole or not at all, and a value worked out over the row is a `set`'s expression or `case`. A batch cannot read a value and branch on it: a decision between two reads is the client's, a round trip each. - **Write from more than one writer.** A file has one: writes run one after another under its lock — 320 000 increments a second in process and 102 000 over HTTP from sixteen clients (`make counters-bench`), 185 800 jobs claimed and acked a second (`make queue-bench`) — and reads go beside them: four threads read 701 000 rows a second by id beside four writers (Writers and readers at once). Past one machine's writer the data is split by tenant, a file each ([Sharding design](https://fenecdb.com/docs/sharding-design#why-tenants-and-not-hash-partitioning)); there are no hash slots. - **Fan out across tenants.** A subscription and `/_changes` are one file's: on a tenant node each tenant is a file of its own, and the router forwards a stream to the node holding it. There is no channel every tenant's subscribers hear; a message for all of them is a write to each. - **Pub/sub without storage.** Every message is a write that lands in the file and moves the change counter; there is no fire-and-forget channel. A row with a short `@ttl` and a subscription is the nearest ([above](#notification)). - **Speak RESP.** A Redis client cannot connect: the server speaks HTTP, and every language reaches it through it or an SDK (Your language). - **Bitmaps, Bloom filters and Top-K.** No `SETBIT` or `BITCOUNT`, no Bloom or Cuckoo filter, Count-Min sketch or Top-K. Membership is exact, a row a member (Sets); HyperLogLog is there (Unique visitors). - **Deliver exactly once.** A queue's lease that lapses under a slow worker hands its job to another, and a consumer that crashes before it commits reads again: the work's own check — a `@unique` key, an `Idempotency-Key` — makes the second run harmless. --- Source: https://fenecdb.com/docs/queues # Job queues Many workers taking jobs from one collection: each job claimed by one of them under a lease, then acked, extended or failed — every step a statement. One statement claims. A `set` with `order` and `limit` writes the rows a `get` with the same clauses would answer, and `returning` hands them back as written: ```text set jobs {owner: $1, run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 10 returning * ``` The ten oldest ready jobs are found and written under the one writer's lock, so two workers never take the same one — what PostgreSQL's `FOR UPDATE SKIP LOCKED` is for, with no locks to skip. A job's `run_at` is the time it may next run: when it was enqueued, or later for a delayed one, and while a worker holds it, the end of its lease. So a worker that dies holds nothing for long: its lease lapses, the job is ready again, and the next claim takes it. See [Writes that pick their rows](https://fenecdb.com/docs/fenecql-writing#pick) for the rules. This is Redis's `LPUSH` and `BRPOP`, with a lease so that a job outlives the worker that took it. The other jobs an app adds Redis for — locks, rate limits, caches, streams, pub/sub — are recipes of [Instead of Redis](https://fenecdb.com/docs/redis). Every FenecQL block on this page is run, statement by statement, by a test (`crates/fenec-core/tests/queues_docs.rs`), and each `-- → x` is the answer it checks: a write's count, or the first value of the first row it answered. A line `-- 20 s later` moves the test's clock. The costs are `make queue-bench` on an M1 Air, over a million jobs. ## The queue A collection a queue. `run_at` takes `@sorted`, so a claim walks the ready jobs from the oldest and stops at its page. ```fenecql create collection jobs (payload json, run_at timestamp @sorted, owner text, attempts int, error text) put jobs {payload: {"to": "ada@example.com"}, run_at: now(), attempts: 0} put jobs [{payload: {"to": "bo@example.com"}, run_at: now(), attempts: 0}, {payload: {"to": "cy@example.com"}, run_at: now(), attempts: 0}] put jobs {payload: {"to": "di@example.com"}, run_at: now() + 3600000, attempts: 0} -- → 1: delayed an hour ``` - **A delayed job** is one whose `run_at` is ahead: no claim takes it before then. - **Jobs enqueued at the same time** come out in the order they were written, their ids'. - **Several queues** are several collections. Kept in one, a `queue` field goes without `@hash`: the walk tests it on each job it passes, where through a hash index a claim would read the whole queue to put it in order. ## Claim Each worker claims a batch, the oldest ready jobs, each its own for 30 seconds: ```fenecql set jobs {owner: "w1", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 2 returning id, payload.to -- → 1: jobs 1 and 2 set jobs {owner: "w2", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 2 returning id, payload.to -- → 3: the one left ready set jobs {owner: "w3", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 2 returning id -- → no row: none ready ``` - **None is taken twice.** The claim finds and writes its jobs under the write lock. Sixteen threads claiming batches of ten from 6 000 jobs, and eight HTTP clients from 2 000, never handed a job to two of them, and every job was acked (`crates/fenec-core/tests/claims.rs`, `crates/fenec-http/tests/claims.rs`). - **`returning *`** answers every field; a list answers those, paths into a `json` field among them. Without `returning` the answer is the count. - **`limit 1 require 1`** is one job or a refusal (412): `require` counts the rows the claim answers. - **An empty queue answers no rows** at once — or, sent over HTTP with `Fenec-Wait`, holds the claim at the server until a job comes ([Waiting for a job](#wait)). ## Waiting for a job A worker whose claim finds nothing need not sleep and claim again. Sent with `Fenec-Wait`, milliseconds, the claim is held at the server while it would take nothing, and answered the moment it takes a job — one enqueued, a delayed one come due, a lease lapsed — or at the end of the wait with no row: Redis's `BLPOP`, SQS's long poll. ```bash curl -X POST http://127.0.0.1:8080/query -H 'Fenec-Wait: 30000' \ -d '{"query": "set jobs {owner: $1, run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 10 returning *", "params": ["w1:1842"]}' ``` - **A job enqueued** reaches a waiting worker in 0.38 ms at the median and 0.76 at p99, where a worker polling every 10 ms took 0.83 and 10.1 and every 100 ms 15.5 and 85.8 — and polling every 10 ms sent ten times the claims (costs). - **Time wakes it too.** No write announces a delayed job's `run_at` or a lease's end, so the server works the moment out from the claim's filter: `run_at <= now()` changes its answer only as the time passes a job's `run_at`, and the next one is a step of the `@sorted` index past now. The claim is held until that moment and runs then, never before. - **One job, many workers:** it goes to the worker that has waited longest, and the others stay held. Claims that would take the same jobs — the same filter, scope and parameters in it, whatever they write — wait in line, and the line looks once whether a job is there; the first runs, and having taken a job hands the turn to the next, since more may be ready. A write to the queue costs a line one look under the read lock, not a claim a worker. - **Claims of their own, one job:** it goes to the claim whose own order puts it first, and on a tie to the one that has waited longest. A courier's claim is a line of its own -- its point is in its filter, its token's subject in its rules -- so every courier's claim looks when an order is placed, all of them by one held request under one read lock, each among the orders placed since it last looked; each that finds a row bids for it, and once every claim that could beat the best bid has looked, the row is the best's, its claim alone is woken, and the rest wait behind its run ([Who gets the job](#fairness)). - **At most 30 seconds**, and the answer is then what the claim answers on an empty queue: `[]`, or a `require`'s 412. A worker sends the next. - **A `set` or a `del`**, alone or in a `/batch` beside reads: a `put` writes every time, and a request holding one is refused (400), as is a filter that reads the time other than as a field compared with `now()` give or take a number — `bucket(now(), 60000)` — since nothing says when its answer changes. ### Who gets the job A job every waiting claim would take goes to one claim, by the claim's own order: - **A claim ordering by a field** -- `order run_at`, a queue's -- ranks a job as every other claim does, so the tie goes to the claim that has waited longest: first come, first served across claims as within one line, as Redis serves `BLPOP`'s clients. A burst of jobs goes to the waiting claims in the order they came, the oldest job to the claim held longest, each claim looking for the next once the run before it is done. - **A claim ordering by its own point** -- a courier's `order distance(pickup, $1) limit 1` ([The nearest job](#nearest)) -- ranks a job by how far it is from the courier, so of the couriers waiting the nearest takes it, the others looking again once its run is done, each for the job nearest it then. That needs every waiting claim's look, which costs what was written rather than what each claim would read: one order at a time to couriers waiting in a city 10 km across, the nearest took every one, run for once, 0.18 ms from the put at 10 couriers, 0.31-0.32 ms at 100 and 1.7 ms at 1 000, the server's CPU 0.2, 0.4-0.5 and 3.1-3.2 ms an order, against a raced 0.2-0.4 ms to a courier a median of 3.3-5 km further, two runs an order (`make queue-bench`'s `held nearest`, in turns). - **No header turns it on.** The claim's order says what "first" means, and a tie keeps the order claims came in. How one look picks the winning claim, measured Raced, which it was before, an order placed woke every courier's claim and whichever ran first took it: of ten couriers waiting within reach the nearest was handed it one time in ten, of a hundred one in a hundred, and every claim that found the order ran under the write lock for nothing. One job every 2 ms at least to claims of their own that would all take it, raced in brackets (`make queue-bench`'s `held contested`, in turns): | Claims waiting | Held longest took it | Runs a job | Put to taken, p50 | Server CPU a job | | 10 | 100% (4-6%) | 1 (5.2-5.3) | 0.30-0.39 ms (0.37-0.43) | 0.7-0.9 ms (1.8-2.1) | | 100 | 100% (2%) | 1 (5.2-5.5) | 0.75-0.80 ms (0.57-0.68) | 1.8-2.3 ms (11-14) | | 1 000 | 100% (3%) | 1 (4.7-5.2) | 2.4 ms (2.6-5.2) | 5.9 ms (138-144) | Each claim looking for itself, a job placed beside a thousand claims of their own woke a thousand threads, each running its whole pick under the read lock: 0.38-0.49, 0.86-0.91 and 10-12.5 ms from the put at 10, 100 and 1 000 claims, the server's CPU 1.9-2.3, 12-13 and 110-126 ms a job; over 2 000 leased orders a courier's pick took 80-86 µs, the point index walked past every one, and an order took 20 ms to reach the nearest of 1 000 couriers and 175 ms of the server's CPU. Now one held request looks for every claim under one read lock and wakes only the claim whose bid won, and a claim whose last look found nothing knows no job it could take is there but those written since, until the time reaches its next moment: it tests those alone, with its own filter and in its own order, a microsecond a claim, the job its whole pick would have found -- held so over 3 000 generated rounds of claims, tokens' rules and writes. A claim where that cannot be shown -- an inner `get`, rows that expire, a schema changed, more than 32 rows written since -- runs its whole pick, under the same lock. A thousand claims held also take less memory, 46 KB each against 63, their threads no longer each running a pick. A tie settles as soon as the claim held longest has looked: no claim held after it could take the job before it. That look, made with every other claim's, is what the job waits for, where raced it went to the fastest of a thousand claims woken at once; the one run a job, against five under the write lock, is every other writer's. A row a claim ranking by its own point may bid for waits for every claim's look. A single line of workers -- the same claim -- is served as before: a job enqueued reached one of eight in 0.35 ms either way. When a held claim runs again A held claim is the same request run at a later moment: the first moment it would write something, or the end of the wait. - **When it runs again.** A write to a collection it reads has its line look under the read lock whether a job is there to take — the claim's own filter as a `get` of one row — and the line's first claim is run only if one is. A write of another collection wakes none: woken by every write, 150 claims held on `orders`, each a line of its own, took a put of `positions` beside them from 22 µs at the median to 50-95, and at p99 from 30-90 to 200-480 µs; now it takes 22-25 and 30-97, as beside none. A look that found nothing is not repeated for a millisecond, however many writes land meanwhile: beside sixteen claims held on a queue that stayed empty, sixteen busy workers took 148 000 jobs a second against 150 600 with none held, the held ones looking 730 times a second; woken at every write they looked 3 400 times and the workers took 142 000. A job enqueued in that millisecond is taken at most that much later. The time wakes it at the next row's moment: each comparison of a field with `now()` plus or minus a number, wherever it is in the filter, and a `@ttl` field's expiry; every row's value counts, so one queue among several in a collection may be woken by another's delayed jobs, to find nothing and sleep to the next. - **A delayed job** was held by a waiting worker 0.04 ms after its time at the median and 1.9 at p99, against 1.2 and 8.4 polling every 10 ms; with nothing due and nothing written, a held claim does nothing at all: a thousand held for ten seconds took no measurable CPU, where a thousand polling every 100 ms took 86% of a core, and a hundred 11%. - **Each held claim is a connection** and its thread, as a subscription is: 46 KB of the server's memory each at a thousand, against a subscription's 45. They count against `--max-connections`, which a server with many waiting workers raises. On a tenant node a tenant's held claims are its `held` quota, apart from its `streams` -- a quarter of the node's connections unless set ([quotas](https://fenecdb.com/docs/sharding-reference#quota-bounds)) -- and a claim costs its tenant's rate of rows written only when a run writes: one held while the tenant is past its rate stays held, and runs once a row's worth is back. - **Under an `Idempotency-Key`** a claim that waited and then took jobs keeps them under the key: sent again, it is answered those rows and takes none. While it takes nothing it keeps no key, so a retry after a timeout waits again. - **A worker's token** waits as it claims: what a look reads is the claim as the token runs it, its rules ANDed in, so it wakes for jobs it could take and no others, and each run asks the token again — one that expired meanwhile is refused (401). - **A client gone** before a job came hands the job to the next waiting worker, rather than leasing it to no one — through the router too, which passes its client's end on to the node. - **Tenants and the router.** Under `/t//` a held claim holds nothing of its tenant between its runs, so a move goes on; a tenant moved away or deleted answers its held claims 503 with `Retry-After`, to come back through the router, which waits for a held request its wait longer than `--upstream-timeout`. A claim held on a node the router fails over is answered then: under an `Idempotency-Key` it is held again on the tenant's new primary for what is left of its wait; without one it is a `502`, since the node may have run it as it died, and sent again it would take a second job ([Requests out to a node failed over](https://fenecdb.com/docs/sharding-design#in-flight)). - **Shutdown.** At SIGTERM every held claim is answered at once, with the nothing it found, before the server syncs and exits. - **A replica** refuses a claim as it refuses every write (403), held or not. - **With `Fenec-After`** the header is that read's wait, as before, and holds no write. - **Not in a page or an app's library.** A database in the page, or one an app opens through the native library, has no other worker to wait for, and the builders refuse `wait` there: an app that queues work for itself knows when it enqueues, and a live query of the queue runs again at every write. ## Ack, extend A worker acks a job it holds by deleting it, and keeps one longer by moving its lease on. Each names the owner it was claimed by and requires its row, so a worker whose lease lapsed, and whose job another worker claimed meanwhile, finds out: ```fenecql del jobs where id = 1 and owner = "w1" require 1 -- → 1: done -- 20 s later set jobs {run_at: now() + 30000} where id = 2 and owner = "w1" require 1 -- → 1: 30 s more -- 15 s later set jobs {owner: "w3", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 10 returning id, attempts -- → 3: w2's lease lapsed del jobs where id = 3 and owner = "w2" require 1 -- → refused: 412, w3 holds it now del jobs where id = 3 and owner = "w3" require 1 -- → 1 ``` - **At least once.** A job whose worker died is taken again once its lease lapses, so a job can run twice — once by a worker that was only slow. Work that must not happen twice keeps a check of its own: a `@unique` key on what it writes, or an [`Idempotency-Key`](https://fenecdb.com/docs/http#idempotency) on the request it sends. - **A token a claim** rather than the worker's name as the owner — `"w1:1842"` — also tells apart two claims of one job by the same worker. - **To keep a job done** rather than delete it, give it no ready time: `set jobs {run_at: null, owner: null} where id = $1 and owner = $2 require 1`. No claim takes a job without one. ## Fail, back off, dead-letter A job that failed goes back with a backoff that grows with its attempts, and after the fifth it has no ready time — a dead letter, which no claim takes and a read finds: ```fenecql set jobs {owner: null, error: "smtp timeout", run_at: case when attempts >= 5 then null else now() + 1000 * attempts * attempts end} where id = 2 and owner = "w1" require 1 -- → 1: again in 1 s -- 2 s later set jobs {owner: "w1", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at limit 1 returning id, attempts -- → 2: its second attempt set jobs {attempts: 5} where id = 2 -- three failures on set jobs {owner: null, error: "smtp timeout", run_at: case when attempts >= 5 then null else now() + 1000 * attempts * attempts end} where id = 2 and owner = "w1" require 1 -- → 1: the fifth: dead get jobs select id, error where run_at is null -- → 2: the dead letters set jobs {run_at: now(), attempts: 0, error: null} where id = 2 and run_at is null -- → 1: sent again by hand ``` - **The backoff here is the square** of the attempts in seconds — 1, 4, 9, 16 — worked out over the row. One that doubles, or has jitter, is the worker's to send: it read `attempts` in its claim. - **A worker that dies on a job** never fails it, and its lease lapses instead: `attempts` counts claims, so a job that comes back to a worker with `attempts` past five is failed by it, not run again — SQS's receive count. ## Priorities With a priority, a claim takes the highest first, and among jobs of one priority those written first: ```fenecql create collection tasks (payload json, run_at timestamp @sorted, priority int @sorted, owner text, attempts int) put tasks [{payload: {"n": 1}, run_at: now(), priority: 0, attempts: 0}, {payload: {"n": 2}, run_at: now(), priority: 9, attempts: 0}] set tasks {owner: "w1", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order priority desc limit 1 returning payload.n -- → 2: the higher priority ``` - **The walk is the priority's index**, testing each job's ready time as it passes: jobs of a high priority that are leased or delayed are passed over at every claim, so a queue holding many of them is a collection of its own. While the ready jobs number a few thousand at most, the claim reads them by `run_at` and puts them in order instead. ## The nearest job Where a job has a place and a worker one of its own — a pickup and a courier, a ride and a driver — the claim takes the nearest ready job, in the same one statement: a key of `order` may be an expression, and the distance from a point is walked to the page by a `@geo` index ([The nearest job](https://fenecdb.com/docs/geo#dispatch)): ```fenecql create collection rides (pickup geo @geo, run_at timestamp @sorted, owner text, attempts int) put rides [{pickup: [29.0100, 41.0400], run_at: now(), attempts: 0}, {pickup: [29.0300, 41.0500], run_at: now(), attempts: 0}] set rides {owner: "w1", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order distance(pickup, [29.0290, 41.0490]) limit 1 returning id -- → 2: the nearer set rides {owner: "w2", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order distance(pickup, [29.0290, 41.0490]) limit 1 returning id -- → 1: the one left ``` - **Any expression orders the claim** — `order priority * 10 - attempts desc`, `order case when vip then 0 else 1 end, run_at` — every ready job's key worked out and the page put in order. Only the distance from a point over `@geo` and an `@sorted` field are walked to the page. - **The nearest worker to each job, where they wait.** A claim takes the job nearest the worker that asks; claims held at the server for a job (`Fenec-Wait`) hand a job placed to the waiting worker nearest it, each claim's own distance its rank ([Who gets the job](#fairness)). ## A pop Where a job may be lost but never run twice — Redis's `LPOP` — a `del` takes and answers it at once: ```fenecql del tasks where run_at <= now() order priority desc limit 10 returning id, payload -- → 1: taken and gone ``` ## A worker The loop, in JavaScript over HTTP: claim a batch — held at the server until there is one — run each job, and ack it or fail it. ```ts import { connect, expr, inc, raw } from '@fenecdb/web/client'; const db = connect('http://127.0.0.1:8080', { token: 'a worker token' }); const jobs = db.from('jobs'); const me = `w-${crypto.randomUUID()}`; async function send(to: unknown): Promise { // the work } for (;;) { const owner = `${me}:${Date.now()}`; const claimed = await jobs .where(raw('run_at <= now()')) .order('run_at') .limit(10) .update( { owner, run_at: expr('now() + ?', 30000), attempts: inc(1) }, { returning: true, wait: 30000 }, ); for (const job of claimed) { try { await send((job.payload as { to: string }).to); await jobs.where('id', job.id).where('owner', owner).delete({ require: 1 }); } catch { const backoff = 1000 * 2 ** Number(job.attempts); await jobs.where('id', job.id).where('owner', owner).update( { owner: null, run_at: expr('now() + ?', backoff) }, { require: 1 }, ).catch(() => {}); } } } ``` - **Every builder writes the claim** — Python's `.update(patch, returning=True)`, Go's `Update(ctx, patch, fenecdb.Returning("*"))` and its `Result.Rows`, .NET's `UpdateAsync(patch, returning: ["*"])`, and Swift's, Kotlin's and Dart's `updateReturning` — held to the same text. - **And waits for a job** the client's way: JavaScript's `{ wait: 30000 }` beside `{ signal }`, Python's `wait=30` (seconds), Go's `db.Wait(30 * time.Second)` under the request's context, .NET's `db.WithWait(TimeSpan.FromSeconds(30))` under its cancellation token, and Swift's, Kotlin's and Dart's `withWait`. Each client waits for the answer as much longer than its own timeout, and gives the request up when it is cancelled. - **Over HTTP** the claim is a `POST /query`; sent under an [`Idempotency-Key`](https://fenecdb.com/docs/http#idempotency), a retry after a timeout is answered the jobs the first try took, and takes none. - **A worker's token** claims through its rules: a policy line `jobs update(owner, run_at, attempts) where queue = $jwt.queue for worker` lets it take its queue's jobs alone and change nothing else of them, and what it answers is held to its read rules as well, as it writes them: a worker that reads only `where owner = $jwt.sub` is answered the jobs it claims. A transition says what a claim may make of a job — `where run_at <= now() and new.owner = $jwt.sub and new.attempts = attempts + 1`, so no claim is made for another worker or wipes the count a dead letter keeps ([Scoped access](https://fenecdb.com/docs/http-access#scoped-access)). - **Not on a sync replica.** A replica would pick its own rows and answer them, and the server its own: a claim there is refused, and goes to the server itself. ## What it costs Over a million jobs, `make queue-bench`: | a million jobs | jobs a second | a claim of 10, p50 | p99 | | one worker, `@sorted` on `run_at` | — | 18.7 us | 25.8 us | | one worker, no index: a scan | — | 132.7 ms | 134.8 ms | | 16 threads, the claim one statement, each job acked | **185 800** | 22.4 us | 1.52 ms | | 16 threads, read, compare-and-set, read back | 93 800 | 0.40 ms | 1.89 ms | | 16 HTTP clients, a claim and a `/batch` of its acks | **138 500** | 0.48 ms | 2.99 ms | | 16 HTTP clients, three round trips to claim | 48 200 | 0.60 ms | 2.51 ms | - **The three statements a client sends without `returning`** — read the ready page, set the jobs only where they are still ready, read back which it won — lose a race whenever two workers read the same page: 43% of the jobs read in process, and 77% over HTTP, had gone to another worker by the time it wrote, and were read again. Half the jobs a second in process, a third over HTTP. - **Sixteen claims wait for each other,** as every write does: a claim's p99 among sixteen threads is the lock's queue, where one worker's is 25.8 us. - **The claim reads its page and no more:** the ready jobs are a range of the index, the time worked out once before the plan. A queue holding a million leased or delayed jobs and three ready claims the three, where a walk testing each job's time would pass every leased one. ## Compared - **PostgreSQL**: `UPDATE jobs SET ... WHERE id IN (SELECT id FROM jobs WHERE run_at <= now() ORDER BY run_at LIMIT 10 FOR UPDATE SKIP LOCKED) RETURNING *`. Its row locks let claims run side by side; here the one writer runs them one after another, each a few microseconds, and none has rows to skip. - **SQS**: a claim is `ReceiveMessage`, the lease its visibility timeout, an extension `ChangeMessageVisibility`, `attempts` its receive count and a dead letter its redrive. A delayed job is `DelaySeconds`, with no fifteen-minute ceiling. - **BullMQ**: delayed, prioritised, retried with a backoff and kept as failed, as here; its jobs live in Redis's memory, these in the file, durable by the server's `--sync` policy, replicated with it, and read with any query. ## What this does not cover - **Exactly once.** A lease that lapses under a slow worker hands its job to another; the work's own check makes the second run harmless. - **Claims side by side.** They are writes, so each waits for the one before: tens of thousands a second, not millions. --- Source: https://fenecdb.com/docs/geo # Points and distances Places on the earth in a collection: the metres between two, every row within a radius or a box, the nearest first — what an app adds Redis's `GEOADD` and `GEOSEARCH` or PostGIS for. A `geo` field holds a point, `[lon, lat]` in degrees. `distance(loc, p)` is the metres between two points, `within(loc, [west, south, east, north])` whether one lies in a box, and `near loc p` puts rows in order nearest first. `@geo` indexes the field, so a radius, a box and the nearest read the rows near the point rather than every row. Every answer is the one a scan of every row gives, row for row. The other jobs an app adds Redis for — counters, locks, caches, job queues, pub/sub — are recipes of [Instead of Redis](https://fenecdb.com/docs/redis). Every FenecQL block on this page is run, statement by statement, by a test (`crates/fenec-core/tests/geo_docs.rs`), and each `-- → x` is the answer it checks: a write's count, or the first value of the first row it answered. The costs are `make geo-bench` on an M1 Air, over a million points. ## Points A point is written as a list of two numbers, its longitude first — GeoJSON's order, and the order Redis and PostGIS take them in. ```fenecql create collection places (name text, kind text @hash, loc geo @geo) put places [{name: "Palermo", kind: "city", loc: [13.361389, 38.115556]}, {name: "Catania", kind: "city", loc: [15.087269, 37.502669]}] -- → 2 get places select loc where name = "Catania" -- → [15.087269,37.502669] ``` - **Redis:** `GEOADD Sicily 13.361389 38.115556 "Palermo" 15.087269 37.502669 "Catania"`. - **Kept as written.** The two numbers are `f64`s and come back as they went in. Redis keeps a 52-bit geohash, which moves a point to the centre of a cell 0.6 m by 0.3 m at the equator: its `GEOPOS` answers `15.08726745843887329` for the `15.087269` it was given. - **A longitude from -180 to 180 and a latitude from -90 to 90,** or the write is refused: a latitude of 91 is most often the two given the other way round. Redis takes latitudes to 85.05 only. - **`null` is no point.** A row without one is in no radius and no box, and `near` answers only rows that hold one. ## Distance `distance(a, b)` is the metres between two points, on a sphere of Redis's radius (6 372 797.56 m) — so it is `GEODIST`'s answer. ```fenecql get places select distance(loc, [15.087269, 37.502669]) as m where name = "Palermo" -- → 166274.2577914073 ``` - **Redis:** `GEODIST Sicily Palermo Catania` answers `166274.1516`, between the points it keeps: 0.11 m off, since it moved each to its cell. Over 2 000 pairs of points, crowded and spread over the earth, the two agree to 0.05 mm from the points Redis keeps (`GEOPOS`), and to 0.54 m from the points as written. - **PostGIS:** `ST_Distance(a, b, false)` over `geography` is on a sphere of the earth's mean radius, 6 371 008.8 m, so its metres are 0.03% fewer: Palermo to Catania is 166 227.6 m. Its default, the WGS84 ellipsoid, differs from a sphere by up to 0.5%: 166 506.2 m. - **The same bits everywhere.** The trigonometry is fenecdb's own, not the platform's: a point on a radius's edge is in or out alike on a server, a phone and a page. ## Within a radius ```fenecql get places select name where distance(loc, [15, 37]) <= 200000 -- → "Palermo": in id order get places select name where distance(loc, [15, 37]) <= 100000 -- → "Catania" get places where distance(loc, [15, 37]) <= 200000 count -- → 2 ``` - **Redis:** `GEOSEARCH Sicily FROMLONLAT 15 37 BYRADIUS 200 km`. - **A radius reaches the index** written `distance(loc, p) <= r` or `< r`, either way round, in an `and` chain, the point and the radius each a literal or a parameter. A ring — `distance(loc, p) > 1000` beside it — is tested on the rows the radius found. - **Any radius:** 0 is the point itself, and one past half the earth's circumference is every row. A radius over a pole takes every longitude, and one across the antimeridian both sides of it. ## Nearest first `near loc p` orders the rows by their distance from `p`, a tie by id, and `_score` is the distance in metres: ```fenecql get places select name, distance(loc, [15, 37]) as m near loc [15, 37] limit 1 -- → "Catania" get places select name where distance(loc, [15, 37]) <= 200000 near loc [15, 37] limit 10 -- → "Catania": then Palermo get places select name where distance(loc, [15, 37]) <= 200000 order distance(loc, [15, 37]) limit 10 -- → "Catania": the same rows ``` - **Redis:** `GEOSEARCH Sicily FROMLONLAT 15 37 BYRADIUS 200 km ASC COUNT 10 WITHDIST`. Redis needs a radius to search in; `near` needs none. - **The index is walked nearest first** and stops at the page: the ten nearest of a million points took 33 to 38 us, whether they were a street or an ocean away. - **`_score` is an `f32`,** as every score is: to the metre out to 16 777 km, and 2 m past it. Select `distance(loc, p)` for the `f64`. - **`near loc p exact`** measures every row, as the collection without the index does: the same answer. - **`order distance(loc, p)`** is the same order as a key of `order`, ties by id, beside other keys and in a write that picks its rows ([The nearest job](#dispatch)). With a `limit` it walks the index as `near` does where no row the filter passes lacks a point; a row with none has a null distance, which comes first, so where some may pass -- no radius or box on the field, and rows without a point -- every row the filter passes is measured. `near` leaves such rows out. ## In a box `within(loc, [west, south, east, north])` holds a point inside the box or on its edge. A box whose west is east of its east crosses the antimeridian: ```fenecql get places where within(loc, [12, 37, 16, 39]) count -- → 2 put places [{name: "Suva", kind: "city", loc: [178.4419, -18.1248]}, {name: "Apia", kind: "city", loc: [-171.7513, -13.8333]}, {name: "Nuku'alofa", kind: "city", loc: [-175.2018, -21.1394]}] -- → 3 get places where within(loc, [170, -25, -170, -10]) count -- → 3 get places select name near loc [180, -15] limit 1 -- → "Suva" ``` - **Redis's `BYBOX`** is a width and a height in metres around a centre; `within` takes the corners, as a map's visible area gives them. The rows of a map's view are `within(loc, $bounds)` with `limit`. - **PostGIS:** `loc && ST_MakeEnvelope(w, s, e, n, 4326)`, whose box does not wrap: one across the antimeridian is two. ## With the rest of a query A radius or a box is one condition of an `and` chain, so the rest of the query goes with it: other conditions, an order, a count, a facet, a `lookup`. ```fenecql put places [{name: "Cafe A", kind: "cafe", loc: [15.0912, 37.5047]}, {name: "Cafe B", kind: "cafe", loc: [15.0855, 37.5076]}, {name: "Fish market", kind: "market", loc: [15.0868, 37.5005]}] -- → 3 get places select name where kind = "cafe" and distance(loc, [15.087269, 37.502669]) <= 1000 near loc [15.087269, 37.502669] limit 5 -- → "Cafe A" get places where kind = "cafe" and distance(loc, [15.087269, 37.502669]) <= 1000 count -- → 2 get places select name where distance(loc, [15.087269, 37.502669]) <= 1000 facet kind ``` - **Whichever index names fewer rows is read,** and the other condition tested on each: a `@hash` bucket of a few dozen cafes, or the rows of a radius narrower than the bucket. A `near` over a set of a few thousand rows measures it whole rather than walk the points. - **A scoped token** finds only the points its rules let it read: its filter is ANDed in as it is into every statement ([Security](https://fenecdb.com/docs/server#security)). - **`or` reads every row,** as it does for any index. Two radii are two statements, or one statement each in a `/batch`. ## Positions that expire Couriers, drivers, a user's last known place: each one's position written over as it moves, and gone when it stops being sent. `@ttl` takes a row out of every read once its time is past. ```fenecql create collection couriers (name text @unique, loc geo @geo, seen timestamp @ttl(30s)) put couriers {name: "ada", loc: [15.0901, 37.5031], seen: now()} if absent else set {loc: new.loc, seen: new.seen} -- → 1 put couriers {name: "bo", loc: [15.0702, 37.5120], seen: now()} if absent else set {loc: new.loc, seen: new.seen} -- → 1 get couriers select name near loc [15.087269, 37.502669] limit 1 -- → "ada" -- 20 s later put couriers {name: "bo", loc: [15.0880, 37.5030], seen: now()} if absent else set {loc: new.loc, seen: new.seen} -- → 1: bo moved get couriers select name near loc [15.087269, 37.502669] limit 1 -- → "bo" -- 20 s later get couriers select name near loc [15.0901, 37.5031] limit 1 -- → "bo": ada's position is 40 s old get couriers count -- → 1 ``` - **An upsert a position:** the row made the first time, its point and time set after, one statement whichever it is. - **Gone at once:** a row past its `@ttl` is out of every read the moment it is, and the server's sweeper deletes it one to two minutes later ([Rows that expire](https://fenecdb.com/docs/fenecql-indexes#ttl)). ## The nearest job An order waiting for a courier, a ride for a driver: the job nearest a worker that is ready, taken in one statement under the write lock, so two workers never take the same one. `order` takes the distance as its key in a `set` that picks its rows, and `returning` answers what was taken ([Writes that pick their rows](https://fenecdb.com/docs/fenecql-writing#pick)): ```fenecql create collection deliveries (pickup geo @geo, run_at timestamp @sorted, courier text) put deliveries [{pickup: [15.0912, 37.5047], run_at: now()}, {pickup: [15.0855, 37.5076], run_at: now()}, {pickup: [15.0880, 37.5031], run_at: now() + 600000}] -- → 3 set deliveries {courier: "ada", run_at: now() + 45000} where run_at <= now() and distance(pickup, [15.0901, 37.5031]) <= 3000 order distance(pickup, [15.0901, 37.5031]) limit 1 returning id -- → 1: the nearest ready; 3 is nearer, and waits ten minutes set deliveries {courier: "bo", run_at: now() + 45000} where run_at <= now() and distance(pickup, [15.0901, 37.5031]) <= 3000 order distance(pickup, [15.0901, 37.5031]) limit 1 returning id -- → 2: the one left set deliveries {courier: "cy", run_at: now() + 45000} where run_at <= now() and distance(pickup, [15.0901, 37.5031]) <= 3000 order distance(pickup, [15.0901, 37.5031]) limit 1 returning id -- → no row: none ready within 3 km -- 50 s later set deliveries {courier: "cy", run_at: now() + 45000} where id in (get deliveries select id where run_at <= now() near pickup [15.0901, 37.5031] limit 1) -- → 1: ada's lease lapsed ``` - **The lease is the ready time moved on**: a courier who goes quiet holds the job until it lapses, and the next claim takes it, with nothing written to say so. Its pickup, delivery and every beat are `set ... where id = $1 and courier = $2 require 1`, refused once another holds it ([Job queues](https://fenecdb.com/docs/queues)). - **The radius bounds the walk**: the point index is walked nearest first and stops at the page or 3 km out, whichever comes first. With a radius on the field the walk is taken even where some rows hold no point, since none of those passes it. - **Or an inner `get`** with `near`, run first and its one id written, under the same lock. A `near` takes no `order`, and a write takes no `near` of its own: the distance is a key of `order` there. ## From a client Over HTTP a point is a JSON array, and a filter is FenecQL's: ```bash curl -X POST http://127.0.0.1:8080/places -d '{"name": "Catania", "loc": [15.087269, 37.502669]}' curl 'http://127.0.0.1:8080/places?select=name&where=distance(loc,%5B15,37%5D)%3C%3D200000' curl -X POST http://127.0.0.1:8080/places/near -d '{"field": "loc", "vector": [15, 37], "limit": 10}' curl -X POST http://127.0.0.1:8080/query \ -d '{"query": "get places select name near loc $1 limit 10", "params": [[15, 37]]}' ``` The builders write it as an object condition: ```ts import { connect, distance, type Point } from '@fenecdb/web/client'; import type { FenecSchema } from './fenec-schema.js'; // fenec types app.fenec const db = connect('http://127.0.0.1:8080'); const here: Point = [15.087269, 37.502669]; const cafes = await db.from('places') .select('name', distance('loc', here).as('m')) .where({ kind: 'cafe', loc: { distance: { from: here, lte: 1000 } } }) .near('loc', here) .limit(10) .rows(); const inView = await db.from('places').where({ loc: { within: [12, 37, 16, 39] } }).rows(); ``` Python's, Go's, .NET's, Swift's, Kotlin's and Dart's builders take the same `within` and `distance` conditions and render the same text. `fenec types` writes a `geo` field as each language's pair of doubles, and a Drizzle table's `geometry('loc', { type: 'point' })` with a GiST index is a `geo` field with `@geo`. - **The degrees as written.** Every reader without a schema — a query's parameters, a JSON body, the browser module — reads a list of numbers as a vector's `f32`s, which would move a point by up to 1.7 m at the antimeridian. A list going to a `geo` field or a `distance`, `within` or `near` over one is read again as written. - **In the page.** The browser module answers the same statements over a database in the page or a synced replica, its index and all. ## From PostGIS `fenec import` takes a `geometry(Point, 4326)` or `geography(Point)` column, or PostgreSQL's own `point`, as a `geo` field: ```bash fenec import postgres://app@db:5432/app --table stores --into stores --index "loc@geo" ``` A column of other shapes — lines, polygons — or of another SRID is refused naming it; `--follow` mirrors the table as it changes ([Import](https://fenecdb.com/docs/import)). ## What it costs A million points crowded round 48 cities, as a shop's or a courier's lie, `make geo-bench`: fenecdb in process; PostGIS 3.5 (a `geography(Point)` column, GiST, `ST_DWithin(…, false)` and `<->`) and Redis 7 (`GEOSEARCH`) in Docker, each server's own mean time of the query from `pg_stat_statements`, planning included, and `INFO commandstats` — the network's 0.3 ms left out. The median of a thousand queries near a point of the data: | a million points | rows | fenecdb `@geo` | no index: the scan | Redis | PostGIS | | within 100 m | 0.3 | **7.2 us** | 21.7 ms | 12.6 us | 1.26 ms | | within 1 km | 27 | **12.6 us** | 21.9 ms | 34.3 us | 1.31 ms | | within 10 km | 2 543 | **0.56 ms** | 22.8 ms | 1.73 ms | 4.84 ms | | within 100 km | 36 341 | **5.9 ms** | 28.2 ms | 7.6 ms | 32.2 ms | | the ten nearest within 50 km | 10 | **38 us** | — | 6.0 ms | 1.77 ms | | the ten nearest | 10 | **33 us** | 6.1 ms | 198 ms | 1.75 ms | | the index's build, its memory | | 34 ms, 20 MB | — | 86 MB | 5.4 s, 82 MB | - **A put** with `@geo` costs 1.19 us, against 0.62 without an index and 0.98 with `@sorted` on a float. - **Spread evenly** over the earth, a million points are about 20 km apart: a radius of up to 10 km took 5 to 10 us, 100 km 24 us, the ten nearest 35 us. - **Redis** sorts every member of the cells round the point by distance: the ten nearest anywhere is a search past half the earth, every member measured. **PostGIS** plans each statement again, half a millisecond of its time, and its GiST over `geography` read 132 pages for a radius of 100 m. - **Over HTTP** a request adds its round trip, as it does to PostgreSQL's and Redis's: 0.3 ms through Docker's network here. ## What this does not cover - **Shapes other than points:** no lines, polygons or a point in a polygon. A box is the one area a query takes; a polygon's bounding box narrows, and the app tests the rest. - **The ellipsoid.** Distances are on the sphere, as Redis's are: within 0.5% of WGS84's, which PostGIS's `geography` takes by default. - **A distance between two rows** in one statement: the other row's point is read first and passed as a parameter. - **`GEOHASH` strings and `STORE`**: a search's rows are answered, not written to another key. --- Source: https://fenecdb.com/docs/analytics # Analytics and market data Counts by the minute, distinct users, funnels, bars and VWAP: one statement each, over the rows as they were written. Four things in the select list make these work. An item is an expression — `px * qty`, or an aggregate of one, `sum(px * qty)`, or an expression over aggregates, `sum(px * qty) / sum(qty)`. `group` takes several keys, and `bucket(at, 1m)` makes a time one. `count(distinct user)` counts values, exactly. And `first(px by at)` and `last(px by at)` take the value of the earliest and latest row of a group. See [Aggregates](https://fenecdb.com/docs/fenecql-aggregates#aggregates) for the rules. Every FenecQL block on this page is run, statement by statement, by a test (`crates/fenec-core/tests/analytics_docs.rs`), and each `-- → x` is the first value of the first row it checks. The costs are `make analytics-bench` on an M1 Air, in process, over a million events and a million ticks; over HTTP a request adds its round trip. Counts by the minute and distinct users are what an app often keeps in Redis — `INCR` on a key a minute, `PFADD` and `PFCOUNT`. Here they are statements over the events as they were written; the other patterns, [unique visitors](https://fenecdb.com/docs/redis#unique-visitors) among them, are recipes of [Instead of Redis](https://fenecdb.com/docs/redis). These recipes make a whole application in [examples/analytics](https://github.com/fenecdb/fenecdb/tree/main/examples/analytics): a 572-byte tracker, beacons written once however often they are sent, rollups kept from the change stream exactly once, a dashboard that reads the raw events for a day and the rollups past it, a site a tenant, and the market data below with live prices — with its tests, Lighthouse scores and measurements at ten million events. ## Analytics Raw events, one row each. `at` takes `@sorted` — or `@ttl(30d)`, which is an ordered index whose rows expire — so a time range reads only its rows. ```fenecql create collection events (user int @hash, name text @hash, at timestamp @sorted, country text, props json) put events [{user: 1, name: "view", at: "2026-05-03T09:00:10Z", country: "TR", props: {plan: "pro", v: 3}}, {user: 1, name: "cart", at: "2026-05-03T09:02:00Z", country: "TR", props: {plan: "pro", v: 1}}, {user: 1, name: "buy", at: "2026-05-03T09:05:30Z", country: "TR", props: {plan: "pro", v: 9}}, {user: 2, name: "view", at: "2026-05-03T09:01:00Z", country: "US", props: {plan: "free", v: 2}}, {user: 2, name: "view", at: "2026-05-03T10:15:00Z", country: "US", props: {plan: "free", v: 2}}, {user: 3, name: "view", at: "2026-05-03T10:20:00Z", country: "DE", props: {plan: "free", v: 1}}, {user: 3, name: "buy", at: "2026-05-03T10:40:00Z", country: "DE", props: {plan: "free", v: 5}}, {user: 1, name: "view", at: "2026-05-04T08:00:00Z", country: "TR", props: {plan: "pro", v: 1}}] ``` ### Rollups Counts by the hour, or the minute: `bucket` truncates a timestamp to the interval it falls in, in UTC — `ms`, `s`, `m`, `h`, `d` from the epoch, `w` from a Monday, `mo` and `y` by the calendar. Name it with `as` and group by the name. ```fenecql get events select bucket(at, 1h) as hour, count(*) as n where at >= "2026-05-03" and at < "2026-05-04" group hour -- → "2026-05-03T09:00:00Z" get events select count(*) as n, bucket(at, 1h) as hour where at >= "2026-05-03" and at < "2026-05-04" group hour -- → 4: the first hour's get events select count(*) as n, name, bucket(at, 1d) as day group name, day order n desc, name -- → 4: views on the 3rd get events select sum(props.v) as v, props.plan group props.plan order v desc -- → 14: a json path summed ``` A count by `bucket` of the field a range is on reads its ordered index and no row: per-minute counts over a day of a million events — 1 441 groups of 143 000 rows — take 4.0 ms, where grouping by a minute field written at ingest, which reads the rows, takes 19.5. A raw collection under `@ttl` keeps a few days; a job that runs these statements writes the rollups into a collection without one before the raw rows go. ### A day by a zone's clock `bucket` counts in UTC; a third argument counts by a zone's clock — an IANA name, or an offset written out. A day then starts at local midnight: Istanbul's 3rd of May began at 21:00 UTC on the 2nd. ```fenecql get events select bucket(at, 1d, "Europe/Istanbul") as day, count(*) as n group day order day -- → "2026-05-02T21:00:00Z" get events select count(*) as n, bucket(at, 1d, "Europe/Istanbul") as day group day order day -- → 7: the 3rd in Istanbul get events select count(*) as n, bucket(at, 1h, "+05:30") as hour where at < "2026-05-04" group hour order hour -- → 4: 14:00 to 15:00 in India ``` Where daylight time begins or ends a local day is 23 or 25 hours, and an offset written into the statement — `bucket(at + 10800000, 1d)` — cannot follow it. Berlin's clocks went back on the 25th of October 2026, so that day holds the hour its clock showed twice. ```fenecql create collection beats (at timestamp @sorted) put beats [{at: "2026-10-24T22:30:00Z"}, {at: "2026-10-25T22:30:00Z"}, {at: "2026-10-26T00:30:00Z"}] get beats select bucket(at, 1d, "Europe/Berlin") as day, count(*) as n group day order day -- → "2026-10-24T22:00:00Z" get beats select count(*) as n, bucket(at, 1d, "Europe/Berlin") as day group day order day -- → 2: one day of 25 hours get beats select count(*) as n, bucket(at, 1d) as day group day order day -- → 1: in UTC, three days ``` A page passes its own zone as a parameter — `Intl.DateTimeFormat().resolvedOptions().timeZone` in a browser. The rules are IANA's from 2000 on; a time before it in a named zone is refused, and an offset answers any time. Counted off the ordered index, a week's days of a million events by Istanbul's clock take 29 to 34 ms against 23 to 27 in UTC, where the offset written in reads every row, 133 to 154; its hours by Berlin's clock 31 against 25; with a filter that reads the rows, 94 to 103 against 91 to 98 (`make analytics-bench`). A rollup kept as the events come is a row a key, made at its first count and added to after: `if absent else set` is that upsert, one statement for a page of keys, the set reading the row it finds and `new.` the document put. With the page as a parameter, `put hourly $1 if absent else set {n: n + new.n}`, its text is the same whatever the page holds. ```fenecql create collection hourly (key text @unique, n int) put hourly [{key: "2026-05-03T09|view", n: 2}, {key: "2026-05-03T09|buy", n: 1}] if absent else set {n: n + new.n} -- → 2: made put hourly [{key: "2026-05-03T09|view", n: 3}, {key: "2026-05-03T10|view", n: 2}] if absent else set {n: n + new.n} -- → 2: one added to, one made get hourly select n where key = "2026-05-03T09|view" -- → 5 ``` A page of 1 000 keys, half of them new, is one statement in 1.09 ms in process, where reading which keys exist and writing the new rows and the old ones over took 1.49 ms and a round trip more. ### Distinct users `count(distinct f)` holds each value once a group, exactly, up to a million values a query — about 70 MB — and refuses a query past that rather than count short. ```fenecql get events select count(distinct user) as users, bucket(at, 1d) as day group day -- → 3: on the 3rd get events select count(distinct user) where at >= "2026-05-04" -- → 1 get events select user, count(distinct bucket(at, 1d)) as days group user order days desc, user -- → 1: active two days ``` Distinct users per hour over seven days of a million events, 169 groups: 125 ms. Before, a query an hour listed the users and the client counted them: 7.7 s. Past a million values, or to add days up, count approximately: `approx_count_distinct(f)` is HyperLogLog, 214 registers — 16 KB a group, sparse while a group has few values — with a standard error of 0.81%, whatever the count. `hll_accumulate(f)` answers the sketch itself as bytes, to keep a day's in a rollup row; `hll_combine(s)` merges sketches and `hll_estimate(s)` reads a count off one, so a month is its days' sketches merged — not their counts added, which counts a user of two days twice. Sketches are the same bytes wherever made, the browser's or a server's. A query's sketches are held to 64 MB, 4 096 dense groups, and refused past that. ```fenecql get events select approx_count_distinct(user) as users -- → 3 get events select hll_estimate(hll_accumulate(user)) as users where at >= "2026-05-04" -- → 1 get events select bucket(at, 1d) as day, hll_accumulate(user) as users group day -- → "2026-05-03T00:00:00Z" ``` Per hour over the million events it takes 121 ms, the furthest of the 169 hours 1.3% off; over thirty sketches of 200 000 values the error's root mean square is 0.77%. ### Funnels How many users reached each step, and how many of the viewers bought: ```fenecql get events select count(distinct user) as users, name where name in ["view", "cart", "buy"] group name order users desc -- → 3: viewed get events select count(distinct user) where name = "buy" and user in (get events select user where name = "view") -- → 2: viewed and bought ``` The inner `get` is a list of up to 100 000 values; past that ask from the users' side with `lookup … required` (Subqueries). An ordered funnel — a view, then a buy within the hour — compares each user's first of each: `having` keeps the groups a condition holds for, over their keys, the list's columns by name and any aggregate, and `count` after it counts them rather than send a row a user. ```fenecql get events select user, min(case when name = "view" then at end) as viewed, min(case when name = "buy" then at end) as bought where name in ["view", "buy"] group user having bought >= viewed and bought <= viewed + 3600000 count -- → 2 get events select user, count(*) as n group user having n >= 3 order n desc -- → 1 ``` ### Retention Each user's first day is their cohort, and the days they came back are a group of two keys. The table of cohorts by days since is folded from these two answers on the client. ```fenecql get events select user, min(bucket(at, 1d)) as cohort group user -- → 1 get events select user, bucket(at, 1d) as day, count(*) as events group user, day -- → 1 ``` ## Market data Ticks, one row each, written as they come — not always in time order. ```fenecql create collection ticks (sym text @hash, at timestamp @sorted, px float, qty int) put ticks [{sym: "S07", at: "2026-05-03T10:00:05Z", px: 100.25, qty: 10}, {sym: "S07", at: "2026-05-03T10:00:40Z", px: 101.5, qty: 5}, {sym: "S07", at: "2026-05-03T10:00:20Z", px: 99.75, qty: 20}, {sym: "S07", at: "2026-05-03T10:01:10Z", px: 102.25, qty: 1}, {sym: "S07", at: "2026-05-03T10:01:50Z", px: 101.75, qty: 4}, {sym: "S08", at: "2026-05-03T10:00:30Z", px: 50.5, qty: 100}, {sym: "S08", at: "2026-05-03T10:01:30Z", px: 51.25, qty: 50}] ``` ### Bars Open, high, low, close and volume, a bar a minute. `first(px by at)` is the price of the bar's earliest tick, whatever order they were written in; a tie in `at` goes to the row written first, and `last`'s to the row written last. Without `by` they take the order the rows were written in. ```fenecql get ticks select bucket(at, 1m) as bar, first(px by at) as open, max(px) as high, min(px) as low, last(px by at) as close, sum(qty) as volume where sym = "S07" group bar -- → "2026-05-03T10:00:00Z" get ticks select first(px by at) as open, bucket(at, 1m) as bar where sym = "S07" group bar -- → 100.25 get ticks select last(px by at) as close, bucket(at, 1m) as bar where sym = "S07" group bar -- → 101.5 get ticks select max(px) - min(px) as range where sym = "S07" -- → 2.5 ``` An hour of one-minute bars of one symbol out of a million ticks over 50: 1.7 ms in process, 2.3 over HTTP. Before, open and close were two queries a bar, 120 for the hour: 195 ms in process, 278 over HTTP. ### VWAP The volume-weighted average price is an expression over two aggregates: ```fenecql get ticks select sum(px * qty) / sum(qty) as vwap, sym group sym order sym -- → 100.35625 get ticks select sym, sum(px * qty) as notional, sum(qty) as volume group sym order notional desc -- → "S08" ``` Every symbol's over a million ticks: 118 ms. Before, the client read every tick out and summed them: 173 ms in process, and a million rows over the wire. ### The latest price ```fenecql get ticks select last(px by at) as px, sym group sym order sym -- → 101.75 ``` Every symbol's, a scan of the million ticks: 108 ms, against 129 for a query a symbol (`where sym = $1 order at desc limit 1`, each sorting its symbol's 20 000 rows). A page that shows it all the time keeps a row a symbol instead: ```fenecql create collection quotes (sym text @unique, px float, high float, low float) put quotes {sym: "S07", px: 101.75, high: 101.75, low: 101.75} if absent -- → 1 set quotes {px: 99.5, high: greatest(high, 99.5), low: least(low, 99.5)} where sym = "S07" -- → 1 get quotes select low where sym = "S07" -- → 99.5 get quotes select case when px < high then "below" else "at the high" end as where_it_is where sym = "S07" -- → "below" ``` `greatest` and `least` pass a null over, and `case` works out only the branch it takes. --- Source: https://fenecdb.com/docs/microservices # Services and events Give each service a database of its own, and let other services follow its writes as events. ## A database per service Run one tenant node and give each service its own tenant: a separate file that no other service can read. A token then decides which collections and rows each caller may touch. How a tenant keeps a service apart Each service keeps its own data and tells the others what changed. fenecdb gives a service a database of its own on a shared node, a token that reaches only that, and the writes on its disk as a stream of events — which is what an outbox table and a relay are usually built for. A tenant node (`fenec-server --dir`) keeps a file per tenant, and a tenant is named by the path, `/t//` — never by the query, so one service cannot read another's rows. Give each service its tenant, and a router (`fenec-shard`) in front of the nodes when there are several: see [Tenants and sharding](https://fenecdb.com/docs/sharding). Within a database, a JSON Web Token held to a policy gives each caller the collections, and the rows, its role may read or write ([Scoped access](https://fenecdb.com/docs/http-access#scoped-access)). A service that only reads can be pointed at a replica, which refuses every write ([Replication](https://fenecdb.com/docs/replication)), or hold a token whose policy grants `read` alone. ## Every write as an event Turn on `--cdc` and every write that reaches disk becomes an event a consumer can read. You do not need an outbox table: a batch's rows are announced together. ```bash fenec-server --file orders.fenec --http 127.0.0.1:8080 --cdc # a consumer the server keeps the cursor of: made if it is not there, # left where it stands if it is, so a service can do this at every start curl -X POST http://127.0.0.1:8080/_changes/consumers/billing ``` Why the collection is its own outbox An outbox table exists because a service must change its data and announce the change together, or not at all. A fenecdb `/batch` is one record on disk, so the rows it writes are announced together already: `GET /_changes` hands over every write once it is on disk, the documents with it ([Change data capture](https://fenecdb.com/docs/http-reference#changes)). The collection is its own outbox. Where the event should say something the rows do not — a command, an intent — write it into a collection of its own in the same `/batch`, and it comes in the same record. A consumer in Python: ```python from fenecdb import Client db = Client("http://127.0.0.1:8080", token="...") for batch in db.follow("billing"): # committed when the loop comes back for change in batch: if change["collection"] == "orders" and change["op"] == "put": bill(change["doc"]) # an exception: the batch is read again ``` ## On to Kafka, or anywhere The relay passes a consumer's events on, as JSON lines or to a webhook. Pipe the lines into Kafka with any tool that writes to a topic. ```bash python -m fenecdb.relay http://127.0.0.1:8080 --consumer kafka \ | kcat -P -b broker:9092 -t orders ``` How the relay hands writes on `python -m fenecdb.relay` reads a consumer's writes and hands them on — to standard output, one JSON object a line, or to a webhook — and moves the cursor only once they are out. Kafka takes them through any tool that writes lines to a topic, as above; a webhook: ```bash python -m fenecdb.relay http://127.0.0.1:8080 --consumer search \ --to https://indexer.internal/hook ``` ## Delivery and replay Each event arrives at least once, in order within one database. Make your handler safe to run twice; a consumer that falls too far behind starts over from a fresh read. The delivery guarantees - **At least once.** A consumer that crashes between reading and committing reads those writes again. Key what you do by `seq`, or by the collection and `id`: a `put` carries the whole document, so applying one twice leaves what applying it once did. - **In order, one database at a time.** `seq` is the database's change counter; a batch's writes come together. There is no order across tenants. - **Kept for a while, not for ever.** The server keeps as many writes as `--replication-buffer` holds, 64 MiB unless given. A consumer further behind is answered 410, never handed writes with a gap in the middle. To start over: take the last write on disk (`Fenec-Next` of `GET /_changes` with no `since`), read the collections whole, then go on from that number — a write read in both is applied twice, which a `put` by id makes harmless. - **What is on disk.** A write the server lost in a crash was never handed to anyone, as it was never sent to a replica. - **Reading one's own writes.** A service that writes on the primary and reads on a replica sends the write's `Fenec-Seq` back as `Fenec-After`, and the replica answers once it holds the write ([Reading your own writes](https://fenecdb.com/docs/replication#read-your-writes)). - **Retries.** A service that sends a write again after a timeout sends it with an `Idempotency-Key`, and a write that landed is answered as before rather than made twice ([Retrying a write](https://fenecdb.com/docs/http#idempotency)). ## What it is not It is not a message broker. To keep events for a long time or share them with many teams, relay them into one. What it leaves to a broker A consumer reads one database's writes; there are no topics, partitions or retention of their own, and a server keeps its writes in memory up to the buffer. For fan-out to many teams, a long history or events that outlive the database, relay them into a broker and let it keep them. --- Source: https://fenecdb.com/docs/serverless # Serverless and Cloudflare Run fenecdb inside a Cloudflare Worker, with one small database for each Durable Object. ## Two ways to use it Either the engine runs inside the function, one database per Durable Object, or the function sends its queries to a `fenec-server` server. Inside is faster; a server holds more data and more writers. |   | the engine in the function | the function talks to a server | | where the data is | in the object's memory, kept in its storage | in `fenec-server`'s file | | a query | no network in the object: 2.5 ms for a request's `near` over 10 000 × 128 | a round trip to the server | | writers | one an object; a tenant an object | one a file; a node holds many | | size | about 60 000 rows of 128 dimensions, 12 000 of 768 ([Limits](#limits)) | the machine's disk, the file mapped | | fits | a database a user, a team or a document; RAG over a tenant's own corpus | one large database, many writers, replicas | Durable Objects, and plain HTTP The browser module runs wherever V8 runs a WebAssembly module, a Cloudflare Worker among them. A Durable Object gives it what a file gives a server: one writer, and storage that outlives the process. So one object holds one database, as a tenant is one file on a `fenec-server` node. The second needs nothing new: `Fenec.connect(url)`, the Python `Client` or `fetch`, and a function's connections are HTTP requests, so there is no pool to exhaust. Servers in several regions take one URL through a Worker in front of them, which sends each tenant's requests to its region: the [global entry](https://fenecdb.com/docs/sharding-entry#global-entry). The rest of this page is the first. ## A database a Durable Object The object opens the engine on its first request, restores its database from storage, and saves after every write, before it answers. Its alarm keeps it: rows past their time deleted, a new image when one is worth it. ```ts import { DurableObject } from 'cloudflare:workers'; import { Fenec } from '@fenecdb/web'; import { maintain, persist, release, restore, schedule } from '@fenecdb/cloudflare'; import wasm from '@fenecdb/web/fenec.wasm'; interface Env { TENANT: DurableObjectNamespace; } export class Tenant extends DurableObject { #db: Promise | null = null; #open(): Promise { // Once, with every other event held back while it restores. return (this.#db ??= this.ctx.blockConcurrencyWhile(async () => { const db = await Fenec.open(wasm); // the module as the Worker imports it await restore(db, this.ctx.storage); return db; })); } async query(sql: string, params: unknown[]): Promise { const opened = this.#open(); const db = await opened; try { return db.run(sql, params); } finally { let wrote = 0; try { wrote = await persist(db, this.ctx.storage); // durable before the answer goes } catch (e) { if (this.#db === opened) this.#db = null; // the next request restores throw e; } // The alarm, once something is due: a row that will expire, an image // owed. Nothing else sets it -- an alarm is a row written too. The // write is stored either way, and the next one sets an alarm not set. if (wrote > 0) await schedule(db, this.ctx.storage).catch((e) => console.error(e)); } } async alarm() { const opened = this.#open(); const db = await opened; // Expired rows deleted, an image if one is worth it, the alarm set // again for what comes due next, or none. const done = await maintain(db, this.ctx.storage).catch(async (e) => { await release(db); // nothing more of it is stored if (this.#db === opened) this.#db = null; // the retry restores throw e; }); if (done.error) throw done.error; // stored, the alarm not set: tried again } } export default { async fetch(req, env) { const tenant = env.TENANT.get(env.TENANT.idFromName('acme')); const { sql, params } = await req.json<{ sql: string; params: unknown[] }>(); return Response.json(await tenant.query(sql, params)); }, } satisfies ExportedHandler; ``` Why open and restore run in blockConcurrencyWhile A Worker imports a `.wasm` file compiled, and cannot compile bytes at run time; `Fenec.open` takes the module so. The open and the restore run inside `blockConcurrencyWhile`, once: opened by whichever request found the object cold, two requests could each restore an engine of their own and append to the storage as if alone. `persist` runs in a `finally`, since a statement that failed may follow ones in the same text that wrote. A `persist` that fails leaves the database in memory holding writes its storage never took, so the object lets it go and the next request restores what storage holds, rather than answer from writes nobody kept. Its refusal, and a failed `maintain`'s once `release` has let the database go, come only once nothing of that database is on its way to storage, and nothing more of it will be: the database restored next reads all the first stored and is the only one writing. An error setting the alarm fails neither a write, which is stored, nor `maintain`, which answers it as `error` for the alarm to be tried again. `integrations/cloudflare/example` is this Worker with a tenant from the path, `POST /t//query`, and its test runs it under `wrangler dev` — workerd, as Cloudflare runs it — then stops and starts it over the same storage and reads the rows, a `near`'s answer and each tenant's own database back, and sends a cold object 32 writes at once. ## What the storage holds `persist` saves a full image once, then only the new writes: one row each in a SQLite-backed object's storage, which bills by the row written. If storage stops part way through, the last whole database is still there. |   | rows written before | now | | a single-row write, 1 writer | 3.4 | **1** | | a single-row write, 8 writers at once | 3.4 | **0.52** | | a single-row write, 32 writers at once | 3.4 | **0.15** | | a checkpoint, 10 000 × 128 (a 5.7 MB image) | 101 | **5** | | a checkpoint, 50 000 × 128 (a 27.6 MB image) | 479 | **16** | How persist lays out the image and the writes `persist` keeps what a file would hold: an image the first time, then only the writes since the last call — the engine's journal, as `persist` in the browser takes it for IndexedDB — until they outgrow half the image and 64 KiB, and a new image replaces them. A SQLite-backed object — the only kind a new account makes — has `ctx.storage.sql`, and the database is a table a generation, `fenec:1`, `fenec:2` and on (the key, then the generation): the image's rows, cut under the 2 MB a row takes, then a row each time writes are stored. Nothing names where the database stands beside the rows: the table there is says it, so a write is the one row. A new image is written, and the table before it dropped, in one `transactionSync`: workerd counts a table made as two rows written and a table dropped as none, where a `DELETE` of the rows before counts each, and storage that stops part way holds the generation before, whole. The rows are an `INTEGER PRIMARY KEY` table with no other index, since an index is a row written more. A write goes out at once, unless the one before it is still on its way to being durable (`storage.sync()`): then it waits for that, and every request whose writes land meanwhile — a Durable Object takes the next request in while one waits — goes out with it, in one row. Each `persist` resolves only once its own writes are durable, and a read that may have seen writes on their way waits for them, so no answer leaves before what it shows is kept, and a lone write answers as soon as before: 2.0 to 2.3 ms p50 against 2.4 to 3.1. Waiting a turn of the event loop instead, for requests to land, cost a lone write 1.7 to 2.4 ms more. The figures above are `make cloudflare-bench`'s, under `wrangler dev` with every storage call counted: a write before was a value, a record naming it and an alarm set again, and a checkpoint a value each 128 KiB of the image and a delete each of the values before. A key-value backed object, made before SQLite with `new_classes`, has no `sql`; there the image and the writes are cut into values under its 128 KiB, every image goes under a generation of its own, and the record naming the whole one is written last. A test stops either kind of storage after every number of operations, an image's, an append's and a compact's, and restores the last whole database each time; and a write that fails fails every write that went out with it. A database kept by an earlier version on a SQLite-backed object is in values, which `restore` no longer reads there. To move one, restore it through the key-value methods alone — an object holding `get`, `put`, `delete` and `list` of `ctx.storage` and no `sql` — and `persist` through `ctx.storage`. ## A cold start After an eviction or a deploy, the first request has to load the database again: tens of milliseconds for 10 000 rows, under 200 for 50 000. The alarm's `maintain` folds the writes into a new image once they reach half of the last one, for fewer rows to read and no vectors to link. | rows | first `near`, as written | after a checkpoint | every `near` after | | 1 000 | 20 ms | 21 ms | 2.1 ms | | 10 000 | 39 ms | 47 ms | 2.1 ms | | 50 000 | 175 ms | 157 ms | 2.1 ms | What a cold start costs, measured An object evicted, or a deploy, starts the next request with no database in memory: the module instantiated, the image and the writes after it read out of storage and loaded. The graphs the image holds are restored as they were; the writes after it are applied as the write path would, their vectors linked into the graph one after another — the browser module has one thread. The first `near` after a restart, against the ones after it, under `wrangler dev` on an M1 (`make cloudflare-bench`, median of five restarts; rows of a text, an int and a 128-dim vector, 1 000 a request, each kept before it was answered, no alarm) are in the table above; a second run was within 2 ms of each. Linking the writes after the image is no longer what a start waits for: when this page was first written it took the first `near` over 10 000 rows to 792 ms and over 50 000 to 1 455, and the same measurement on today's module gives 39 and 175, so a checkpoint takes 50 000 rows from 175 ms to 157, 10 000 none quicker, and is mostly worth the rows it folds. The rows are read into the module a row at a time rather than joined first: 50 000 rows as written started in 175 ms against 186 to 196, after a checkpoint 157 against 168 to 171. A request's round trip through `wrangler dev` is in every figure. The rows went in at 5 200 to 15 000 a second, a request of 1 000 at a time with its vectors linked and kept before the answer. The figures under “after a checkpoint” are a new image taken by hand (`checkpoint`); an object that keeps its database from its alarm takes one by the rule below. ## Maintenance A server deletes rows past their `@ttl`, compacts its file and keeps its graphs on threads of its own. An object has one alarm, which is a row written each time it is set and wakes the object each time it rings: `schedule` sets it after a write only once something is due, and `maintain`, its work, sets it again only for what comes next. An idle object sets none. | rows written, 10 000 × 128 | an alarm after every write, a checkpoint past 64 KiB | maintenance | | 30 bursts of 20 single-row writes, a second's quiet after each | 651 to 652 | **600** | | 12 bursts of 500 rows | 100 | **19** | | 20 000 rows past their time, deleted | never | **2** and 2 alarms | How the alarm sweeps expired rows `maintain` deletes the rows a minute (`grace`) past their time, as a server's sweep does, so a reader of `expired()` as frequent sees each before it goes; 10 000 a call (`sweep`) in statements of 1 000 ordinary deletes, stored as any write is, the alarm set again at once past the bound. The object answers nothing while it runs: 1 000 rows of 100 000 took 0.46 ms to delete in the module, so 10 000 hold it about 5 ms, far inside an alarm's 30 s of CPU and 15 minutes. Then it sets the alarm for the next row's time and the grace, no sooner than a minute (`every`) after this sweep, or sets none; each sweep is two rows at most, its deletes and the alarm, so rows expiring a few a second cost two rows a minute rather than two a row. No export of the module was added for it: a `del` naming `expired()` and walking the field's index in order says what the server's sweep does natively. It writes a new image when the writes since the last reach half of it and 64 KiB (`min`), where `persist` folds them in a request, or when half of what storage holds is dead and a mebibyte — what a sweep or deletes left, which the writes' size alone does not show. Woken to sweep and nothing more, it stores the deletes as one row and writes no image. Once `schedule` is called, a request folds the writes only past the whole image: the alarm takes the image at half, and no request waits for it. A threshold of 64 KiB, as the alarm used, wrote a 27.6 MB image as 16 rows for writes that were one or two; at a quarter of the image, rewrites of 1 000 rows of 128 dimensions a request, a row each, brought an 11 MB image, 8 rows, every 6. The module never gives memory back, and holds every version of a record written since it was opened. Once the dead versions reach half of what it holds — at least 4 MiB, at most 16 — `maintain` makes sure storage holds an image without them and answers `reload`: the example lets the database go and restores a new module, as it does on any failure — calls arriving wait while the ones holding it finish, a write on its way to storage among them, then it is released and closed and another restored. Let go of at once, a database an alarm dropped while a request's write waited behind another's stored it after the next request had restored a second database and written, under the same id: an answered write lost. In Node, 50 000 rows of 128 dimensions with three in five past their time held 42.7 MB of module through two sweeps of 10 000; the third left half of storage dead, its image took the module to 51.6 MB, and the module restored from it held 19.2. A `compact` in the module would have rebuilt every graph holding tombstones and built its image whole, and given nothing back. ## Limits An object holds about 60 000 rows of 128 dimensions, or 12 000 of 768. For more, use a `fenec-server` server or spread a tenant over more objects. - **One writer an object.** Writes to one tenant go one after another, as on a server; tenants write side by side. - **Bundle collation data.** Collation data for scripts the module does not carry is fetched from a URL by default; in a Worker, pass `{collation: name => ...}` to `Fenec.open` and hand the chunks from your bundle. - **Install it from npm.** `npm install @fenecdb/cloudflare @fenecdb/web`; both are published with each release. Memory in a 128 MB isolate, measured A Worker's isolate has 128 MB, the WebAssembly memory in it, and `wrangler dev` does not hold a Worker to it: the figures above reach past it. What the module holds, measured in Node on the same module (rows of a text, an int and a vector under HNSW): | rows | restored | written, 1 000 a request | then a checkpoint | the image | | 10 000 × 128 | 16 MB | 24 MB | 28 MB | 6 MB | | 20 000 × 128 | 29 MB | 37 MB | 42 MB | 12 MB | | 30 000 × 128 | 41 MB | 46 MB | 53 MB | 18 MB | | 50 000 × 128 | 67 MB | 73 MB | 83 MB | 30 MB | | 60 000 × 128 | 80 MB | 85 MB | 97 MB | 36 MB | | 3 000 × 768 | 21 MB | 40 MB | 40 MB | 9 MB | | 5 000 × 768 | 35 MB | 58 MB | 58 MB | 16 MB | | 10 000 × 768 | 66 MB | 84 MB | 84 MB | 32 MB | | 12 000 × 768 | 80 MB | 102 MB | 102 MB | 38 MB | A WebAssembly memory grows and never gives back, so an object holds the most it ever held. The module keeps an index's vectors in chunks of about a mebibyte and lets a segment of documents grow no further than the record that seals it: grown by doubling, old and new side by side, 5 000 rows of 768 written 1 000 a request held 100 MB, 58 now, and 10 000 of them 150, 84 now. A restore keeps the image it reads and the documents in it, rather than copy each out: 10 000 rows of 768 restored held 97 MB, 66 now; and it copies the rows into that image as it reads them, one at a time (`loadParts`): read whole, joined, then copied in, a 27.6 MB image — 50 000 rows of 128 — held 60 MB of JavaScript beside the module's 42 at its peak, and holds 4, the process 105 MB more resident against 49 to 55 (`make cloudflare-bench`, in Node). A checkpoint takes the image a mebibyte at a time (`snapshotChunks`), each chunk made as it is taken out of the documents the stores hold, not a copy of them, and let go once it is in storage: made all at once, the image stood beside the rows, and 50 000 rows of 128 went from 73 to 109 MB at it, 83 now. So an object holds about 60 000 rows of 128 dimensions and 12 000 of 768, however many a request brings; past that, a `fenec-server` node and `Fenec.connect`, or a tenant's data spread over more objects. --- Source: https://fenecdb.com/docs/import # Import One command turns a SQLite file or a PostgreSQL table into a fenecdb collection, its fields typed and its indexes built, and can keep it following the table's changes. ## Import a table Name the source, the table and the collection to make. The schema is worked out from the table's columns, and each index you name is built once the rows are in. ```bash fenec import data.sqlite --table docs --into articles --index "category@hash" fenec import postgres://user@host:5432/database --table docs \ --into articles --index "category@hash" ``` ```text $ fenec import data.sqlite --table docs --into articles --index "category@hash" source data.sqlite -> docs target articles.fenec -> articles 3 records, articles.fenec (12.82ms) index category @hash ``` What the import derives and writes The import derives the schema, the table's integer key becoming each document's id, writes the rows in batches and builds the indexes after the last, rather than keep them up a row at a time. The collection goes into `.fenec` unless `--file` names another. A column of embeddings comes across too ([Vector columns](#vector-columns)). Every option is in `fenec import --help`. ## Look before you write `--dry-run` prints the schema it would make, and any warnings, and writes nothing. ```text $ 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 ``` - **`--count`** also prints how many rows will be read. Why --count is a separate flag `--count` is a separate flag because counting means a full scan: SQLite keeps the row count nowhere and `count(*)` in PostgreSQL reads the table from the start. Silently reading everything twice would be a bad default. ## Sources A SQLite file is read directly, and a PostgreSQL server is read over the network. Nothing else needs to be installed for either. - **SQLite:** no `sqlite3` library or binary needed. - **PostgreSQL:** no `psql` or `pg_dump` needed; pgvector's columns are recognised. How each source is read **SQLite.** The file format is read directly — neither the `sqlite3` library nor the binary is needed. `INTEGER PRIMARY KEY` becomes the document id, `--vector field:N` turns little-endian `f32` BLOBs into `vector`, and untyped columns are resolved from the first 1000 rows. **PostgreSQL.** It connects to the live server and streams `COPY ... TO STDOUT` — no `psql`, no `pg_dump`. Authentication is SCRAM-SHA-256, hand-written in `fenec-wire`'s client over `fenec-http`'s own crypto. pgvector's `vector`, `halfvec` and `sparsevec` columns are recognised along with their dimensions. ## Type mapping Each source type becomes the nearest fenecdb type. Types with no exact match are refused until you say what to do with `--cast`. | Source | fenecdb | | `integer` `bigint` `int2/4/8` | `int` | | `real` `double` `float4/8` | `float` | | `text` `varchar` `char` `clob` | `text` | | `blob` `bytea` | `bytes` | | `boolean` | `bool` | | `date` `datetime` `timestamp(tz)` | `timestamp` | | `int[]` `float[]` `text[]` | `[int]` `[float]` `[text]` | | `vector(N)` `halfvec(N)` | `vector` `vector` | | `sparsevec(N)` | `sparse`, read in the same text form | | `geometry(Point, 4326)` `geography(Point)` `point` | `geo`, `[lon, lat]`; another SRID or shape refused ([Points](https://fenecdb.com/docs/geo#postgis)) | | `uuid` | `text` | | `citext`, a SQLite column `COLLATE NOCASE` | `text nocase`, compared by its case fold ([Case-insensitive text](https://fenecdb.com/docs/fenecql-schema#nocase)) — every script's, where SQLite's `NOCASE` folds ASCII alone | | **`numeric` `decimal`** | no equivalent — `--cast` required | | **`json` `jsonb`** | `json`, its numbers as written; a path reads into it ([Objects and paths](https://fenecdb.com/docs/fenecql-schema#json)) | Types with no equivalent, and --cast Types without an equivalent are not silently rounded: say what you want with `--cast =`. On PostgreSQL, `--cast price=text` preserves a decimal exactly. In SQLite a `decimal` is already stored as an `int` or a `float` at the source, so the exact value is gone before the import starts. ## Filtering `--where` keeps only the rows that match a FenecQL expression, written with the new collection's field names. It works the same on both sources. ```bash fenec import data.sqlite --table docs --into articles \ --where 'category = "book" and score >= 10 and tags has "rust"' ``` - **`--source-where`** (PostgreSQL only) filters on the server in SQL, so skipped rows never travel. - **`--limit`** counts rows that pass the filter. Why --where and --source-where are two flags `--where` is a **FenecQL expression** and behaves identically on both sources: it is applied after the row becomes a document and before it is written. The field names are the target schema's — the same expression you would later write with `get`. On PostgreSQL there is also `--source-where`, which goes into the `SELECT` inside the `COPY` so non-matching rows never travel over the wire. That one is **SQL, not FenecQL**. They are separate flags on purpose: one is a portable expression, the other a win pushed down to the source. Both can be used together. ```bash fenec import postgres://... --table docs --into articles \ --source-where "year >= 2024" --where 'tags has "rust"' ``` With a local `--where` in place, `--limit` is not pushed to the source: cutting on the server first and filtering locally afterwards would yield fewer rows than asked for. `--limit` always counts rows that pass the filter. ## Following changes With `--follow` the import keeps running after the copy, applying the PostgreSQL table's changes as they commit. Run the same command again and it picks up where it stopped. ```text $ 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 ``` - **The server needs `wal_level = logical`** and the table an integer key; nothing is installed on it. - **Nothing is lost or applied twice** when the connection drops or the follower is killed. - **Drop the slot when you stop for good,** or PostgreSQL keeps its log for it. How --follow reads, resumes and confirms With `--follow` the import does not end at the copy: it keeps applying the table's inserts, updates, deletes and truncates as they commit, until it is interrupted. Run the same command again and it picks up where it stopped, without a second copy. It reads a logical replication slot through PostgreSQL's own `pgoutput`, so nothing is installed on the server. The publication and the slot are made when missing (`--publication`, `--slot`; both default to `fenec_`). The server needs `wal_level = logical`, and the table needs an integer key that becomes the document id, since a changed row has to be found again. It must also be in the replica identity: the primary key, or `replica identity full`. - **A row arrives as the copy would have written it.** The stream goes through the same type mapping, `--cast`, `--vector` and `--where` as the copy. A row that stops passing `--where` is deleted from the collection. - **Nothing is confirmed before it is on disk.** The slot only moves past a transaction once an fsync has covered it. A broken connection or a killed follower starts again from there, and the writes it replays are puts and deletes by id, so applying them twice changes nothing. - **An untouched vector is not resent.** A vector of 768 floats is 3 KB, past PostgreSQL's TOAST threshold, so an update of any other column arrives without it; the follower takes it from the collection. - **A copy is resumed only when it is whole.** A small `_follow` collection in the file records which collection follows which slot and whether its copy finished. A copy cut short, or one whose slot was dropped in the meantime, is made again rather than streamed on top of, and a collection the follower did not copy is never replaced. Measured against the Makefile's server, 10 000 rows with a `vector(384)`, the mirror a file (`make follow-bench`): |   | vector(384) | vector(768), TOASTed | | commit to visible, one row a transaction | p50 0.16 ms, p99 1.56 ms | p50 0.16 ms, p99 1.57 ms | | one transaction updating every row | 40 400 rows/s | 52 600 rows/s | | 2 000 one-row transactions from one client | visible 0.3 ms after the last | 0.2 ms | | a connection the server cut | 553 ms | 536 ms | **The slot keeps the server's log** until the follower confirms it, for as long as it exists. To stop following for good, drop it: `select pg_drop_replication_slot('fenec_articles')`. PostgreSQL 13 and later can cap what a forgotten slot holds with `max_slot_wal_keep_size`. The follower is the file's only writer, as every fenecdb file has exactly one: stop it to open the mirror elsewhere, or serve the mirror from the server that follows ([below](#serving)). A column added to the table after the copy is reported and left out. A column the collection needs that is dropped from the table stops the follower. `--source-where` and `--limit` do not combine with it: the first narrows the copy but not the stream, and a mirror keeps every row. ## Serving the mirror as it follows `fenec-server --follow` runs the follower inside the server, so the table is served over HTTP and subscriptions while it follows. ```text $ fenec-server --file articles.fenec --http 0.0.0.0:8080 --http-token "$TOKEN" \ --follow postgres://app@db:5432/app --follow-table docs --follow-into articles \ --follow-index "category@hash" opened: articles.fenec following: docs fenec-server 0.1.12: articles.fenec [sync=250 ms] fenec-http 0.1.12 listening on: http://0.0.0.0:8080 [token] follow: docs: slot fenec_articles (created), publication fenec_articles (created) follow: the slot keeps the server's WAL until this server confirms it; to stop following for good: select pg_drop_replication_slot('fenec_articles') follow: copying docs: there is none yet follow: 2000 rows of docs copied follow: docs copied, 100000 rows in 160.10ms follow: docs: streaming its changes ``` - **Import options take a `--follow-` prefix:** `--follow-index`, `--follow-cast` and so on. - **The mirrored collection is read-only** to clients (403); the rest of the file takes writes. Running the follower inside the server A second process over the follower's file corrupts it, so serving the mirror took a copy of it and a process of its own. `fenec-server --follow` runs the follower inside the server instead, over the file the server serves: the table is served over HTTP and subscriptions as it follows, with no second copy. Every option of `fenec import` that says how a row becomes a document — `--index`, `--vector`, `--cast`, `--id`, `--where`, `--batch` — is taken as `--follow-index` and so on, as are `--follow-slot` and `--follow-publication`. The collection takes no write but the follower's: a client's is refused with 403, since the next change from PostgreSQL would write over it. The rest of the file takes writes as ever. A subscriber of the collection (`GET /articles/changes`) hears each change as the follower applies it, and so does a replica of the server. The follower stops with the server, its last changes on disk and confirmed, before the shutdown's checkpoint. A server killed outright starts the stream again from what it last confirmed; the changes it applies again are puts and deletes by id, so nothing is lost and nothing comes twice. An error the follower cannot wait out ends the server rather than leave it answering from a mirror that no longer moves. Against the Makefile's server, 10 000 rows with a `vector(384)` under an HNSW index, a subscriber reading `/changes` over HTTP (`make mirror-bench`): | commit to the subscriber | p50 | p99 | | an update of a title | 0.09 ms | 4.00 ms | | a row inserted with its vector, put into the graph first | 0.19 ms | 3.34 ms | Commit to visible in the file, with no server between (`make follow-bench`), was 0.16 ms at the median on the same machine. Killed while the table took a row a transaction, the server was up again in 75 ms and held every one of the table's 14 063 rows 370 ms after it started. The follower adds 178 KB to `fenec-server`. ## Vector columns A column of embeddings becomes a `vector` field, and an `@hnsw` index named with `--index` is built once every row is in. pgvector's columns are recognised with their dimensions; a SQLite BLOB of little-endian `f32`s is named with `--vector`. ```bash fenec import data.sqlite --table passages --into passages \ --vector embed:384 --index "embed@hnsw(cosine)" fenec import postgres://user@host:5432/database --table passages \ --into passages --index "embed@hnsw(cosine)" ``` ```text $ fenec import data.sqlite --table passages --into passages --vector embed:384 --dry-run source data.sqlite -> passages target passages.fenec -> passages create collection passages ( text text <- text source text <- text embed vector<384> <- blob ) id <- id (INTEGER PRIMARY KEY) nothing was written (--dry-run); use --count for the row count ``` How vector columns are imported The graph is built after the copy, over every vector at once and on every core, which is the order a bulk load wants (Bulk load first, index after). pgvector's `vector`, `halfvec` and `sparsevec` columns become `vector`, `vector` and `sparse` with nothing named; `--follow` keeps them in step as any column, an untouched vector taken from the collection rather than resent (Following changes). ## Speed and testing 100 000 rows × 3 dimensions, index build included, take about 3.1 s from either source. ```bash make import-test # the PostgreSQL arm and --follow — needs Docker cargo test -p fenec-import --test all -- --ignored pg:: follow:: ``` What the import adds to the binary Import is a default feature of the `fenec` binary and accounts for 275 KB of it. `--no-default-features` removes it, and `fenec backup`, `archive`, `restore` and `fenec mcp` with it (502 KB); with the `cli` profile as well, the binary drops from 3013 KB to 1880 KB (`make small`). --- Source: https://fenecdb.com/docs/fenecql # FenecQL The query language you use to create collections, write documents and search them by vector, by text or by filter. FenecQL is a small language: one statement reads or writes a collection of documents, and the same text runs in the browser, in an app, in the shell and over HTTP. This page is a tour of it and the grammar; the reference is a page for each family of statements, listed [below the tour](#pages). [Jump to the grammar.](#statements) An example of the reference with tabs shows the same statement written with each language's [query builder](https://fenecdb.com/docs/languages#builder), `db` a client of the server or the database in an app, and the statement that code sends. Each tab's code is run by that language's tests, as shown, and held to the statement. ## A tour One database, built in the shell a step at a time. Each step is a few statements as written with the shell's answer under them; the timings are from one run, and yours differ. To follow along, open a file with `fenec books.fenec` ([Install](https://fenecdb.com/docs/install) has the download, and The shell the first steps) and type each line at the prompt. A write ends at a `;`. ### Make a collection and write to it A collection has typed fields. `put` writes documents, and the database gives each an `id`. ```text fenec> create collection books (title text, year int, author int, copies int); collection `books` created fenec> put books [{title: "Desert Dunes", year: 2019, author: 1, copies: 3}, {title: "Night Oasis", year: 2024, author: 2, copies: 1}, {title: "Fennec Ears", year: 2021, author: 1, copies: 0}, {title: "Oasis Maps", year: 2016, author: 2, copies: 5}]; 4 records (132.88µs) ``` More in [Writing data](https://fenecdb.com/docs/fenecql-writing). ### Read it back `get` with nothing after it is every row. ```text fenec> get books id title year author copies -- ------------ ---- ------ ------ 1 Desert Dunes 2019 1 3 2 Night Oasis 2024 2 1 3 Fennec Ears 2021 1 0 4 Oasis Maps 2016 2 5 (4 rows, 102.58µs) ``` More in [Reading data](https://fenecdb.com/docs/fenecql-reading). ### Filter and order `select` names the fields to return, `where` keeps the rows, `order` sorts them and `limit` takes a page. ```text fenec> get books select title, year where year >= 2020 order year desc title year ----------- ---- Night Oasis 2024 Fennec Ears 2021 (2 rows, 147.79µs) fenec> get books select title, copies where copies > 0 order year limit 2 title copies ------------ ------ Oasis Maps 5 Desert Dunes 3 (2 rows, 157.21µs) ``` More in [Reading data](https://fenecdb.com/docs/fenecql-reading). ### Change it A `set` can read the row it writes, and `require 1` refuses the statement unless exactly one row changed. `insert … returning id` answers the id it made. ```text fenec> set books {copies: copies + 2} where title = "Fennec Ears" require 1; 1 records (156.21µs) fenec> insert books {title: "Salt Roads", year: 2022, author: 1, copies: 4} returning id; id -- 5 (1 rows, 110.04µs) ``` More in [Writing data](https://fenecdb.com/docs/fenecql-writing). ### Index it A filter works without an index, by reading the rows; an index makes one kind of question fast. `explain` runs a query and says how it ran: here the ordered index is walked and stops at the page. ```text fenec> create index on books (year) @sorted; index built on `books.year` fenec> explain get books where year >= 2020 order year desc limit 2 plan ---------------------------------------------------- order: walked the ordered index on year desc, 2 rows rows: 2 (2 rows, 250.79µs) ``` More in [Indexes](https://fenecdb.com/docs/fenecql-indexes). ### Search the words A `@text` index lets `match` rank rows by how well their words match. `fuzzy` finds a word typed wrong, and `highlight()` says where in the text the words are. ```text fenec> create index on books (title) @text; index built on `books.title` fenec> get books select title match title "oasis" title _score ----------- ------ Night Oasis 0.8755 Oasis Maps 0.8755 (2 rows, 258.04µs) fenec> get books select title, highlight(title) match title "oasys" fuzzy title highlight(title) _score ----------- ---------------- ------ Night Oasis [[6, 11]] 0.8755 Oasis Maps [[0, 5]] 0.8755 (2 rows, 404.92µs) ``` More in [Text search](https://fenecdb.com/docs/fenecql-search). ### Follow a key into another collection `lookup` attaches another collection's rows to each row, by a key that collection indexes. The shell prints them as joined rows; over JSON they nest under `books`. ```text fenec> create collection authors (name text, country text); collection `authors` created fenec> put authors [{name: "Ada", country: "UK"}, {name: "Bo", country: "TR"}]; 2 records (92.46µs) fenec> create index on books (author) @hash; index built on `books.author` fenec> get authors select name lookup books on author select title order year name books.title ---- ------------ Ada Desert Dunes Ada Fennec Ears Ada Salt Roads Bo Oasis Maps Bo Night Oasis (5 rows, 181.67µs) ``` More in [Lookup and subqueries](https://fenecdb.com/docs/fenecql-lookup). ### Count and group An aggregate in the select list folds rows into one, and `group` makes one row per key. ```text fenec> get books select author, count(*) as books, sum(copies) as copies group author order books desc author books copies ------ ----- ------ 1 3 9 2 2 6 (2 rows, 154.25µs) ``` More in [Aggregates and grouping](https://fenecdb.com/docs/fenecql-aggregates). Not in the tour: [vector search](https://fenecdb.com/docs/fenecql-vector), which finds the rows whose embedding is closest to one you pass, and [types](https://fenecdb.com/docs/fenecql-schema) such as `json`, `timestamp` and `geo`. ## The reference pages A page for each family of statements: what it is for, a run you can repeat, then every rule, with the measurements and the internals folded under a line that says what is inside. - [Reading data](https://fenecdb.com/docs/fenecql-reading) `get`, `where`, `order`, `limit`; pages by parameters, columns worked out, `explain`. - [Writing data](https://fenecdb.com/docs/fenecql-writing) `put`, `insert`, `set`, `del`; upserts, `require`, `returning`, writes that pick their rows. - [Collections and types](https://fenecdb.com/docs/fenecql-schema) `json` fields and paths, points, time, collation, `nocase`, `alter collection`. - [Indexes](https://fenecdb.com/docs/fenecql-indexes) Hash, ordered and unique indexes, indexes over several fields, rows that expire. - [Lookup and subqueries](https://fenecdb.com/docs/fenecql-lookup) Attach another collection's rows to each row, or filter by them. - [Aggregates and grouping](https://fenecdb.com/docs/fenecql-aggregates) `count`, `sum`, `group`, `having`, `bucket` and time zones. - [Text search](https://fenecdb.com/docs/fenecql-search) `match`, typos, highlights, facets and hybrid search. - [Vector search](https://fenecdb.com/docs/fenecql-vector) `near`, quantized indexes and sparse vectors. ## Statements FenecQL is a small language with vectors in the core. It is not SQL and does not try to be, but it reads SQL's everyday spellings as the statements they mean — `select … from`, `CREATE TABLE`, `INSERT INTO … VALUES`, `UPDATE … SET a = …`, `JOIN` as a `lookup`, `IN (…)`, `EXISTS`, `DISTINCT`, `BETWEEN`, `LIKE`, `ON CONFLICT` — and refuses the rest saying what to write: see [From SQL](https://fenecdb.com/docs/sql). These are all its statements. ```fenecql create collection [if not exists] ( [required] [collate und|tr] [nocase] [@index], ... [, @sorted( [desc], ...) | @unique(, ...)] ) drop collection [if exists] create index [if not exists] on ( | ) @index create index [if not exists] on ( [desc], [desc], ...) @sorted | @unique drop index [if exists] on (, , ...) alter collection add field [collate und|tr] [nocase] [@index] alter collection drop field alter collection rename field to alter collection alter field @ttl() | @sorted put { field: value, ... } -- or [ {...}, {...} ] insert [into] { field: value, ... } -- as put, refusing an id that is taken put { field: value, ... } if absent -- a held id or @unique value passed over, and not counted put { ... } if absent else set { n: n + new.n } -- an upsert: the row holding it set, new.f the document's put $1 [if absent ...] -- the documents a parameter holds: an object, or a list of them insert into (a, b) values (1, 'x'), ... [on conflict do nothing | do update set a = ...] -- SQL's spelling update set a = , b = [where ...] -- SQL's spelling of set get [select a, b] [where ] [near [ef N] [exact]] [match [fuzzy [N]] [prefix]] [rerank [candidates N]] [fuse [k N] [candidates N]] -- match and near, both ranking [order [collate und|tr] [asc|desc], ...] [limit N] [offset N] -- N or a parameter, $2 -- a key: a field, or an expression, distance(loc, $1) [facet [top N | ranges [..]] [disjunctive], ...] -- value counts over every matched row [lookup on [= ] [required] ]... [lookup on (, ...) = (, ...) ...] -- a key over several fields get select a, highlight(f [, pre, post]), snippet(f, N [, ellipsis [, pre, post]]) match ... -- where the words matched get where in (get select ...) -- a subquery's list get [where ] count -- number of matching rows get select [,] count(*), count(distinct e), sum(e), avg(e), min(e), max(e), first(e [by k]), last(e [by k]), approx_count_distinct(e), hll_accumulate(e), hll_combine(sketch) [as ], ... [where ] [group , ... [having ] [order [desc]] [limit N] [offset N] [count]] get select a, as ... -- a column worked out over each row explain get ... -- the path the query took select a, b from [where ...] -- the classic SQL order works too set { field: value, path: value } [where ] -- a value may read the row: {n: n + 1} del [where ] [order [desc], ...] [limit N] [returning * | a, b] -- the page a get answers, written and answered [returning * | a, b] -- the rows written, ids and all, in the documents' order require N -- refused, and its batch put back, unless it wrote N rows get ... require N [lookup ...] -- refused, and its batch put back, unless it answered N rows collections | describe | compact [] -- a path, meta.source.rank, reads into a json field wherever a field is read ``` - **Every document gets an `id`** on its own. Give one in a `put` and it writes over the document with that id. - **`insert` never writes over.** If an id is taken, the whole statement is refused; `put … if absent` passes over it instead, and the answer counts what was written; `put … if absent else set {n: n + new.n}` sets the row holding it instead, an upsert in one statement, `new.f` the document's field. - **A `set` can read the row it writes.** `set hits {n: n + 1} where key = "ip1"` is an increment, made under the write lock — see [Writes that read the row](https://fenecdb.com/docs/fenecql-writing#writes). - **A `set` or a `del` can pick its rows and answer them.** `order` and `limit` write the page a `get` with them would answer, and `returning` hands it back: a job queue's claim in one statement — see [Writes that pick their rows](https://fenecdb.com/docs/fenecql-writing#pick). A `put` or an `insert` answers the rows it wrote with `returning`, the ids it made among them. - **A page's numbers take a parameter.** `limit $2 offset $3`, and a facet's `top $4`: every page of a list is one statement, which a server parses once — see [Pages by parameters](https://fenecdb.com/docs/fenecql-reading#paging). - **`require N` makes a count a condition.** A write that does not write exactly `N` rows, or a `get` that does not answer exactly `N`, is refused, and the batch it is in is put back whole — see [Writes that must find their rows](https://fenecdb.com/docs/fenecql-writing#require). - **`alter collection` rewrites no document.** A field added reads `null` in the documents already there; a field's type does not change — see [Changing a collection](https://fenecdb.com/docs/fenecql-schema#changing-a-collection). - **A statement ends where the next one begins, or at a `;`.** No separator is needed, and a statement may run over several lines: the parser reads where each ends. A `;` ends the statement before it, so no clause goes on past one — `del notes; where id = 3` is refused, not read as one statement, and so is a `;` inside brackets, `in (get … ;)`. A clause that is a statement too begins one after it: `get a lookup b on k; select z` is two statements, the second a read of `z`. A query string's `select=`, `group=` and `facet=` take a `;` after their list as they always did. How the shell runs statements written over lines is in [The shell](https://fenecdb.com/docs/shell#statements-over-lines). How insert and put treat an id The `id` field is automatic. Supplying `id` inside a `put` turns it into an upsert. `insert` makes documents and never writes over one: a document naming an id that is taken refuses the statement, and nothing of it is written — `409` over HTTP, as PostgreSQL's `INSERT` refuses a taken key. Without an `id` the two are the same. A retried write that must not be made twice either way sends an [`Idempotency-Key`](https://fenecdb.com/docs/http#idempotency). ## Reference Types, indexes, operators and functions at a glance. | **Types** | `bool` `int` `float` `text` `bytes` `timestamp` `vector` `sparse` `[type]` `json` `geo` (a point, `[lon, lat]` — see [Points](https://fenecdb.com/docs/fenecql-schema#geo)) | | **Case** | `email text nocase` compares by its case fold wherever it is compared — `=`, its indexes, `order`, `group` — and keeps what was written — see [Case-insensitive text](https://fenecdb.com/docs/fenecql-schema#nocase) | | **Paths** | `meta.source.rank` reads into a `json` field in `where`, `select`, `order` and `set`, and takes `@hash`, `@unique` and `@sorted` — see [Objects and paths](https://fenecdb.com/docs/fenecql-schema#json) | | **Indexes** | `@sorted(owner, created desc)` and `@unique(org, slug)` over several fields (see [Indexes over several fields](https://fenecdb.com/docs/fenecql-indexes#composite)), `@hash`, `@unique`, `@sorted`, `@ttl(30m)` (a `timestamp` field's rows expire — see [Rows that expire](https://fenecdb.com/docs/fenecql-indexes#ttl)), `@text(k1=.., b=.., prefix=.., chars)`, `@hnsw(metric, m=.., ef_construction=.., ef_search=.., quant=int8|bit)`, `@inverted` (a `sparse` field's), `@geo` (a `geo` field's) | | **Defaults** | `@hnsw(cosine, m=16, ef_construction=200, ef_search=100)`; `ef_search=200` with `quant=bit` | | **Metrics** | `cosine` `l2` `dot` | | **Operators** | `=` `!=` (or `<>`) `<` `<=` `>` `>=`, `~` text contains (case-insensitive), `has` list contains, `in [..]` (or `in (..)`), `in (get …)` — see [Subqueries](https://fenecdb.com/docs/fenecql-lookup#subqueries), `is null`, `between a and b`, `like` and `ilike` with `%` and `_`, `not in`, `not like`, `not between` — see [From SQL](https://fenecdb.com/docs/sql) | | **Arithmetic** | `+` `-` `*` `/` on numbers, a `timestamp` moved by milliseconds, and `+` (or `||`) joining two texts — in a `set`'s values and in `where`; see [Writes that read the row](https://fenecdb.com/docs/fenecql-writing#writes) | | **Logic** | `and` `or` `not` — `and` binds tighter | | **Parameters** | `$1`, `$2`, … as in PostgreSQL, wherever a value goes, and as `limit`, `offset` and a facet's `top` — see [Pages by parameters](https://fenecdb.com/docs/fenecql-reading#paging) | | **Functions** | `lower` `upper` `len` `coalesce` `greatest` `least` `now` `expired` ([rows past their `@ttl`](https://fenecdb.com/docs/fenecql-indexes#reaper)) `timestamp` `bucket(at, 15m [, zone])` `cosine` `l2` `dot` `norm` `normalize` `distance(loc, p)` `within(loc, box)` ([Points](https://fenecdb.com/docs/fenecql-schema#geo)), `case when … then … else … end`, plus plugins — see [Functions over a row](https://fenecdb.com/docs/fenecql-reading#functions) | | **Ordering** | `order year desc, title asc` — a tie on the first key is decided by the second; `id` can be ordered too; a key may be any expression over the row, `order distance(loc, $1)`, `order price * qty desc` (see [Ordering by an expression](https://fenecdb.com/docs/fenecql-reading#order-by-expression)); `order name collate und` puts text in Unicode's order for every language and `collate tr` in Turkish order, and a field declared `name text collate und` is in it wherever it compares — see [Collation](https://fenecdb.com/docs/fenecql-schema#collation) | | **Picking** | `set … order run_at limit 10 returning *` writes the rows a `get` with the clauses answers and hands them back, as written; `del … returning` as they were; `insert … returning id` the rows it made — see [Writes that pick their rows](https://fenecdb.com/docs/fenecql-writing#pick) | | **Explain** | `explain get ...` runs the query and returns the path it took, one row a step — see [Explain](https://fenecdb.com/docs/fenecql-reading#explain) | | **Typos** | `match body $1 fuzzy` matches each word within the edits its length allows, a word as typed ranking first; `prefix` matches the last word as the start of one, as a search box is typed into — see [Typos and words begun](https://fenecdb.com/docs/fenecql-search#typos) | | **Highlights** | `highlight(body)` and `snippet(body, 20)` in the select list mark the words a `match` found, as UTF-16 offsets or between tags you pass — see [Highlights and snippets](https://fenecdb.com/docs/fenecql-search#highlights) | | **Facets** | `facet brand, color top 10` counts each value over every row the query matches, beside the page — see [Facets](https://fenecdb.com/docs/fenecql-search#facets) | | **Counting** | `count` returns one row with one column; it does not combine with `select`, `near`, `match`, `order`, `limit` or `offset` | | **Aggregates** | `count(*)` `count(…)` (the rows it is not null in) `count(distinct …)` `sum` `avg` `min` `max` `first(… by …)` `last(… by …)` `approx_count_distinct` `hll_accumulate` `hll_combine` over fields, paths or expressions, whole or per `group` of one key or several, the groups kept by `having` — see [Aggregates](https://fenecdb.com/docs/fenecql-aggregates#aggregates) and the [analytics and market-data recipes](https://fenecdb.com/docs/analytics) | | **Columns** | `select px * qty as notional`: an expression over the row, named with `as` — see [Columns worked out](https://fenecdb.com/docs/fenecql-reading#columns) | | **Ceilings** | `near`, `match` and `fuse` at most 10 000 rows (`limit + offset`, a parameter's as a number written), `rerank` at most 10 000 candidates, `fuse` 10 000 a side; a word of a `fuzzy` or `prefix` `match` 10 000 words of the index; expression depth 512 levels; a `json` value 64 levels deep, a path 64 keys; `in (get …)` 100 000 values and 4 levels — see [Limits](https://fenecdb.com/docs/limits) | --- Source: https://fenecdb.com/docs/fenecql-reading # Reading data `get` a collection's documents, filtered, ordered and paged. `get` reads a collection: `select` names the fields to return, `where` the rows to keep, `order` their order, and `limit` and `offset` the page. Every clause is optional, so `get films` alone is every row. Run in [the shell](https://fenecdb.com/docs/shell#quickstart), answers and all; the timings are from one run. ```text fenec> create collection films (title text, year int, rating float, tags [text]); collection `films` created fenec> put films [{title: "Dune Run", year: 1999, rating: 7.1, tags: ["sand", "chase"]}, {title: "Night Heist", year: 2008, rating: 8.2, tags: ["heist", "night"]}, {title: "The Quiet Oasis", year: 2015, rating: 7.8, tags: ["sand", "drama"]}, {title: "Neon Heist", year: 2021, rating: 6.9, tags: ["heist", "neon"]}, {title: "Desert Fox", year: 2023, rating: 8.6, tags: ["sand", "drama"]}]; 5 records (163.25µs) fenec> get films select title, year where year >= 2000 and rating >= 7.5 order rating desc title year --------------- ---- Desert Fox 2023 Night Heist 2008 The Quiet Oasis 2015 (3 rows, 168.96µs) fenec> get films select title where tags has "heist" order year desc title ----------- Neon Heist Night Heist (2 rows, 138.79µs) fenec> get films select title, year order year desc limit 2 offset 1 title year --------------- ---- Neon Heist 2021 The Quiet Oasis 2015 (2 rows, 126.12µs) fenec> get films where rating >= 7 count count ----- 4 (1 rows, 85.92µs) ``` A row has its `id` unless `select` leaves it out, and `count` answers one row with one column. A `get` can also be held to a count, `require N`, which refuses it and its batch when it answers another ([A read that must find its rows](https://fenecdb.com/docs/fenecql-writing#read-require)). Only filters joined by `and` reach an index ([Indexes](https://fenecdb.com/docs/fenecql-indexes)), and `explain`, below, says which one a query used. The operators, logic and ordering are in [the reference table](https://fenecdb.com/docs/fenecql#reference). Reading across collections, totals and search have pages of their own: [Lookup and subqueries](https://fenecdb.com/docs/fenecql-lookup), [Aggregates and grouping](https://fenecdb.com/docs/fenecql-aggregates) and [Text search](https://fenecdb.com/docs/fenecql-search). ## Pages by parameters `limit`, `offset` and a facet's `top` take a parameter wherever they take a number: a `get`'s, a `lookup` level's, an inner `get`'s, and a `set`'s or a `del`'s that picks its rows. ```fenecql get articles where year = $1 order published desc limit $2 offset $3 get products limit $1 lookup reviews on product_id order created desc limit $2 get products match title $1 limit $2 facet brand top $3 set jobs {owner: $1} where run_at <= now() order run_at asc limit $2 returning * ``` ```js await db.from('articles') .where('year', 2024) .order('published', 'desc') .limit(20) .offset(40) .rows(); await db.from('products') .limit(20) .lookup( 'reviews', { on: 'product_id', order: [['created', 'desc']], limit: 3 } ) .rows(); await db.from('products') .match('title', 'tent') .limit(20) .facet('brand', { top: 10 }) .rows(); await db.from('jobs') .where(raw('run_at <= now()')) .order('run_at') .limit(10) .update({ owner: 'w1' }, { returning: true }); ``` ```python (db.collection("articles") .where("year", 2024) .order("published", "desc") .limit(20) .offset(40) .rows()) (db.collection("products") .limit(20) .lookup("reviews", on="product_id", order=[("created", "desc")], limit=3) .rows()) (db.collection("products") .match("title", "tent") .limit(20) .facet("brand", top=10) .rows()) (db.collection("jobs") .where(raw("run_at <= now()")) .order("run_at") .limit(10) .update({"owner": "w1"}, returning=True)) ``` ```go db.From("articles"). Where("year", "=", 2024). Order("published", "desc"). Limit(20). Offset(40). Rows(ctx) db.From("products"). Limit(20). Lookup( "reviews", fenecdb.On("product_id"), fenecdb.Sort("created", "desc"), fenecdb.Limit(3), ). Rows(ctx) db.From("products"). Match("title", "tent"). Limit(20). Facet("brand", fenecdb.Top(10)). Rows(ctx) db.From("jobs"). WhereCond(fenecdb.Raw("run_at <= now()")). Order("run_at", "asc"). Limit(10). Update(ctx, fenecdb.D("owner", "w1"), fenecdb.Returning("*")) ``` ```csharp await db.From("articles") .Where("year", 2024) .Order("published", "desc") .Limit(20) .Offset(40) .RowsAsync(); await db.From("products") .Limit(20) .Lookup( "reviews", on: "product_id", order: [new SortKey("created", "desc")], limit: 3 ) .RowsAsync(); await db.From("products") .Match("title", "tent") .Limit(20) .Facet("brand", top: 10) .RowsAsync(); await db.From("jobs") .Where(Cond.Raw("run_at <= now()")) .Order("run_at") .Limit(10) .UpdateAsync(new { owner = "w1" }, returning: ["*"]); ``` ```swift try await db.from("articles") .where("year", 2024) .order("published", "desc") .limit(20) .offset(40) .rows() try await db.from("products") .limit(20) .lookup( "reviews", on: "product_id", order: [SortKey("created", "desc")], limit: 3 ) .rows() try await db.from("products") .match("title", "tent") .limit(20) .facet("brand", top: 10) .rows() try await db.from("jobs") .where(.raw("run_at <= now()")) .order("run_at") .limit(10) .updateReturning(["owner": "w1"] as Value) ``` ```kotlin db.from("articles") .where("year", 2024) .order("published", "desc") .limit(20) .offset(40) .rows() db.from("products") .limit(20) .lookup( "reviews", on = "product_id", order = listOf(SortKey("created", "desc")), limit = 3 ) .rows() db.from("products") .match("title", "tent") .limit(20) .facet("brand", top = 10) .rows() db.from("jobs") .where(Cond.raw("run_at <= now()")) .order("run_at") .limit(10) .updateReturning(mapOf("owner" to "w1")) ``` ```dart await db.from('articles') .where('year', 2024) .order('published', 'desc') .limit(20) .offset(40) .rows(); await db.from('products') .limit(20) .lookup( 'reviews', on: 'product_id', order: [SortKey('created', 'desc')], limit: 3 ) .rows(); await db.from('products') .match('title', 'tent') .limit(20) .facet('brand', top: 10) .rows(); await db.from('jobs') .where(Cond.raw('run_at <= now()')) .order('run_at') .limit(10) .updateReturning({'owner': 'w1'}); ``` ```fenecql get articles where year = $1 order published desc limit $2 offset $3 -- [2024, 20, 40] get products limit $1 lookup reviews on product_id order created desc limit $2 -- [20, 3] get products match title $1 limit $2 facet brand top $3 -- ["tent", 20, 10] set jobs {owner: $1} where run_at <= now() order run_at asc limit $2 returning * -- ["w1", 10] ``` - **Every page is one statement.** Written into the text, each page size and each page was a text of its own, which a server parses unless its parse cache still holds it; as parameters a list is one text whatever page is asked. Every builder sends them so. - **A parameter is the number it holds.** It is checked as the statement runs, before the plan: `limit $1` handed 10 answers what `limit 10` answers, row for row, through the same plan, and `explain` names the number. - **It is refused as the number written would be,** in the same words: a count is a whole number from 0 (`20.0`, a float with no fraction, is 20), and `-1`, `2.5`, a text or `null` is ``limit` takes a whole number from 0, not -1`. Past a ceiling it meets the check the number written meets: `near` and `match` at 10 000 rows of `limit + offset`, a facet's `top` from 1 to 10 000. A count past what memory could hold is every row. - **`require N` stays a number written.** Its count is what the statement guards — `require 1`, "this row and no other" — a constant of the code, which never makes a text of its own; a page's size and place are a request's data. How a limit or offset parameter is read The statement keeps the parameter, so what reads a statement by its shape reads `limit $2` as a limit: `count` and an aggregate without `group` refuse `offset $1` as they refuse `offset 5`, and a sync replica refuses a synced `set … limit $1` as it refuses `limit 1`. What reads it by its number reads the number it is handed: a long read is pinned or not by the page it asks for, as a number written is. `/_stats/statements` counted a page size written in as a place already, `limit $3`; the parse cache (`POST /query`, 16 shards of 64, a shard emptied when full) kept a text a page. ## Ordering by an expression A key of `order` is a field, or any expression over the row: a distance, a product, a `case`. The rows a `get` answers, and those a `set` or a `del` picks, are put in its order as by a field's, ties in id order: ```fenecql get orders order n * 2 desc, item limit 10 get orders order case when customer = "bo" then 0 else 1 end, id ``` - **Every match's key is worked out** and the page put in order: the cost follows the matches, as an order over a field without `@sorted` does. A field beside an expression keeps its own collation and fold; an expression's text orders by its bytes, or in the collation `collate` names. - **`distance(, )` ascending** over a field with `@geo`, with a `limit`, walks the point index nearest first and stops at the page, as `near` does — where no row the filter passes lacks a point, whose null distance comes first: the index holds every row, or the filter puts a radius or a box on the field ([Nearest first](https://fenecdb.com/docs/geo#nearest)). - **Not in every place an order goes:** over groups a key names a column of the list (give the expression a name with `as`); a `lookup` level orders its children by fields; a key holds no inner `get`, aggregate or `expired()`. ## Columns worked out An item of the select list can be an expression over the row: its fields and paths, parameters and functions, with `+` `-` `*` `/`. It answers under the name `as` gives it; a field can be named again the same way. ```fenecql get ticks select sym, px * qty as notional where qty > 100 get ticks select px as price, bucket(at, 1m) as minute, greatest(px, $1) as floor get orders select id, case when total >= 100 then "large" else "small" end as size ``` ```js await db.from('ticks') .select('sym', expr('px * qty').as('notional')) .where('qty', '>', 100) .rows(); await db.from('ticks') .select( expr('px').as('price'), bucket('at', '1m').as('minute'), expr('greatest(px, ?)', 100).as('floor') ) .rows(); await db.from('orders') .select( 'id', expr('case when total >= ? then ? else ? end', 100, 'large', 'small').as('size') ) .rows(); ``` ```python (db.collection("ticks") .select("sym", expr("px * qty").as_("notional")) .where("qty", ">", 100) .rows()) (db.collection("ticks") .select( expr("px").as_("price"), bucket("at", "1m").as_("minute"), expr("greatest(px, ?)", 100).as_("floor") ) .rows()) (db.collection("orders") .select( "id", expr("case when total >= ? then ? else ? end", 100, "large", "small").as_("size") ) .rows()) ``` ```go db.From("ticks"). Select("sym", fenecdb.Expr("px * qty").As("notional")). Where("qty", ">", 100). Rows(ctx) db.From("ticks"). Select( fenecdb.Expr("px").As("price"), fenecdb.Bucket("at", "1m").As("minute"), fenecdb.Expr("greatest(px, ?)", 100).As("floor"), ). Rows(ctx) db.From("orders"). Select( "id", fenecdb.Expr("case when total >= ? then ? else ? end", 100, "large", "small").As("size"), ). Rows(ctx) ``` ```csharp await db.From("ticks") .Select("sym", Computed.Expr("px * qty").As("notional")) .Where("qty", ">", 100) .RowsAsync(); await db.From("ticks") .Select( Computed.Expr("px").As("price"), Computed.Bucket("at", "1m").As("minute"), Computed.Expr("greatest(px, ?)", 100).As("floor") ) .RowsAsync(); await db.From("orders") .Select( "id", Computed.Expr("case when total >= ? then ? else ? end", 100, "large", "small").As("size") ) .RowsAsync(); ``` ```swift try await db.from("ticks") .select("sym", .expr("px * qty").as("notional")) .where("qty", ">", 100) .rows() try await db.from("ticks") .select( .expr("px").as("price"), .bucket("at", "1m").as("minute"), .expr("greatest(px, ?)", 100).as("floor") ) .rows() try await db.from("orders") .select( "id", .expr("case when total >= ? then ? else ? end", 100, "large", "small").as("size") ) .rows() ``` ```kotlin db.from("ticks") .select("sym", Computed.expr("px * qty").alias("notional")) .where("qty", ">", 100) .rows() db.from("ticks") .select( Computed.expr("px").alias("price"), Computed.bucket("at", "1m").alias("minute"), Computed.expr("greatest(px, ?)", 100).alias("floor") ) .rows() db.from("orders") .select( "id", Computed.expr("case when total >= ? then ? else ? end", 100, "large", "small").alias("size") ) .rows() ``` ```dart await db.from('ticks') .select(['sym', Computed.expr('px * qty').as('notional')]) .where('qty', '>', 100) .rows(); await db.from('ticks') .select([ Computed.expr('px').as('price'), Computed.bucket('at', '1m').as('minute'), Computed.expr('greatest(px, ?)', [100]).as('floor') ]) .rows(); await db.from('orders') .select([ 'id', Computed.expr('case when total >= ? then ? else ? end', [100, 'large', 'small']).as('size') ]) .rows(); ``` ```fenecql get ticks select sym, px * qty as notional where qty > $1 -- [100] get ticks select px as price, bucket(at, 1m) as minute, greatest(px, $1) as floor -- [100] get orders select id, case when total >= $1 then $2 else $3 end as size -- [100, "large", "small"] ``` How a select expression is typed and named The types are a `set`'s ([Writes that read the row](https://fenecdb.com/docs/fenecql-writing#writes)): two `int`s make an `int`, and one past 64 bits is refused, never wrapped; a null is null. An expression is bound to the collection once a query — each field it reads by its place, every field the list reads decoded in one pass a row — and its error comes at the first row it fails on. An item that is neither a field nor an aggregate of one has no name of its own: `select px * qty` is refused, and asks for `as`. ## Functions over a row Functions work wherever an expression goes: `where`, a `set`'s values, a select list's items and a group's keys. ```fenecql get events select bucket(at, 15m) as q, count(*) group q get events where bucket(at, 1d) = "2026-05-03" count set bars {high: greatest(high, $1), low: least(low, $1)} where key = $2 get orders select id, case when total is null then 0 when total < 0 then -1 else 1 end as sign ``` - **`bucket(ts, interval)`** is the start of the interval a `timestamp` falls in, in UTC: `ms`, `s`, `m`, `h` and `d` count from the epoch, `w` from a Monday as ISO's weeks begin, and `mo` and `y` by the calendar — `bucket(at, 3mo)` is a quarter. The interval is written as a duration, `15m`, or passed as a parameter holding one as text or a number of milliseconds. An `int` of epoch milliseconds gives an `int`. - **`bucket(ts, interval, zone)`** is the same by a zone's clock: an IANA name, `bucket(at, 1d, 'Europe/Istanbul')`, in any case, or an offset written out — `'+03:00'`, `'-0530'`, `'+03'`, `'Z'`, `'UTC'` — or a parameter holding either, which is how a page passes its own. A day, a week, a month or a year starts at local midnight — the first instant whose clock reads that date, where a clock skips midnight the instant it jumps past it — so where daylight time begins or ends a day is 23 or 25 hours. A shorter interval starts each time the clock reads its start or jumps past it: the hour a clock shows twice is two buckets, and one it skips none. The rules are IANA's from 2000 on, of the tzdata the build was made with (2026c), each zone's transitions and the rule after them; a time before 2000 in a named zone is refused rather than answered by rules the build does not hold, and an offset answers any time. A named zone's table is 31 KB that native builds carry and the browser module is handed beside its collation data the first time a statement names one. - **`greatest(a, b, …)` and `least(a, b, …)`** are the largest and smallest of their values, as PostgreSQL's are: a null is passed over, and the answer is null only when every value is. - **`case when c then v … else e end`** is the value of the first condition that holds, or the `else`, or null. Only the branch taken is worked out: `case when qty = 0 then null else px / qty end` divides only where it may. ## Explain Put `explain` in front of a `get` to see how the query ran: which index it used and how many rows each step read. ```fenecql explain get events where cat = "c7" order created desc limit 20 plan order: the ordered index on created not walked, an equality names fewer rows filter: the hash index on cat, 9753 rows, which is the answer order: created desc, every key read, 9753 matches, the first 20 put in order rows: 20 ``` - **It runs the query**, because some choices depend on the data. - **The wording is for reading**, not parsing; it may change between versions. A worked explain over a million events `explain` in front of a `get` runs the query and answers with the path it took instead of its rows: one row a step, in the order the steps ran, in a single `plan` column. The last row is the size of the answer. ```fenecql explain get events where cat ~ "c77" order created desc limit 20 plan order: walked the ordered index on created desc, 2153 rows tested, 20 kept rows: 20 ``` Both are over a million events, `cat` carrying `@hash` and `created` `@sorted`. The first filter names a hundredth of the rows through an index, so they are sorted; the second is one no index narrows, so the walk tests rows newest first until twenty pass. It runs the query because several choices depend on the data rather than the text: whether a filter's rows outnumber the ANN's budget, whether a walk along an ordered index fills the page before it gives up, whether the ANN came up short and fell back. The steps name each of them. | A step that starts | says | | `filter:` | which index narrowed the filter — the id index, a hash index, an `in` over one, an ordered index's range — or that it was a full scan, how many rows were tested, and whether the scan stopped at the page | | `order:` | a walk along an ordered index and how many rows it tested, why one was not walked, or the sort: how many matches, how many put in order | | `near:` | the ANN and its `ef`, the filter's set searched exactly, the probe that stopped past the budget, the fallback when the ANN came up short, and the wider beam or exact search when tombstones cut it short | | `match:`, `rerank:` | the text index and how many rows it ranked, the vectors read for the rerank | | `fuse:` | the rank offset, and how many candidates each side brought — after its own `match:` and `near:` steps | | `lookup:`, `required:` | the probe per parent and how many children it attached — over several fields the index and whether its runs were walked in the level's order or probed and sorted; which side `required` was answered from | The same statement goes through every transport: the shell prints the rows, `POST /query` returns them as JSON, and the JavaScript builder has `.explain()`, which resolves to the lines. The steps are for reading, not parsing — their wording may change between versions. ## Counting ```fenecql get articles where year >= 2024 count -- one row, one column ``` ```js await db.from('articles').where('year', '>=', 2024).count(); ``` ```python db.collection("articles").where("year", ">=", 2024).count() ``` ```go db.From("articles").Where("year", ">=", 2024).Count(ctx) ``` ```csharp await db.From("articles").Where("year", ">=", 2024).CountAsync(); ``` ```swift try await db.from("articles").where("year", ">=", 2024).count() ``` ```kotlin db.from("articles").where("year", ">=", 2024).count() ``` ```dart await db.from('articles').where('year', '>=', 2024).count(); ``` ```fenecql get articles where year >= $1 count -- [2024] ``` --- Source: https://fenecdb.com/docs/fenecql-writing # Writing data `put`, `insert`, `set` and `del`, and how a write says what it expects to find. A write names a collection and answers how many rows it wrote: `put` and `insert` add documents, `set` changes the rows a `where` keeps, and `del` removes them. Run in [the shell](https://fenecdb.com/docs/shell#quickstart), answers and all; the timings are from one run. ```text fenec> create collection stock (sku text @unique, qty int); collection `stock` created fenec> put stock [{sku: "tea", qty: 10}, {sku: "jam", qty: 2}]; 2 records (152.00µs) fenec> insert stock {sku: "tea", qty: 1}; error: duplicate: `stock.sku` is unique, and document 1 holds "tea" already fenec> put stock {sku: "tea", qty: 1} if absent; 0 records (94.58µs) fenec> put stock {sku: "tea", qty: 5} if absent else set {qty: qty + new.qty} returning sku, qty; sku qty --- --- tea 15 (1 rows, 157.00µs) fenec> set stock {qty: qty - 3} where sku = "tea" and qty >= 3 require 1 returning sku, qty; sku qty --- --- tea 12 (1 rows, 194.00µs) fenec> set stock {qty: qty - 3} where sku = "jam" and qty >= 3 require 1; error: unmet: `set stock` wrote 0 rows, and requires 1 fenec> del stock where qty < 3 returning sku; sku --- jam (1 rows, 115.71µs) fenec> get stock select sku, qty sku qty --- --- tea 12 (1 rows, 85.21µs) ``` `insert` never writes over a row and is refused when a `@unique` value or an id is taken; `put … if absent` passes over such a row and says it wrote none, and `else set` changes it instead, an upsert in one statement. A `set` can read the row it writes, `require N` makes the count a condition and refuses the statement, and the batch it is in, when it is not met, and `returning` answers the rows written or removed. The sections below take each in turn, and the last ones put several statements in one batch. ## Writes that read the row A value in a `set` can be an expression over the row it writes: its fields, parameters and functions, with `+` `-` `*` `/`. Every write answers how many rows it changed, so a write that names what it expects to find says whether it found it. ```fenecql set hits {n: n + 1, seen: now()} where key = "ip1" -- an increment set hits {n: coalesce(n, 0) + $1} where key = $2 -- from nothing, by a parameter set docs {body: $1, version: version + 1} where id = $2 and version = $3 -- compare-and-set: 1 or 0 put locks {name: "nightly", owner: $1, at: now()} if absent -- 1 if taken, 0 if held insert journal {entry: $1 + ":dr", amount: 0 - $2} -- two texts joined ``` - **An increment is atomic.** The row is read and written under the write lock, and there is one writer, so increments from any number of clients all land. - **A field read is the row as it was.** In `{n: n + 1, m: n}` both read the old `n`; a name on the value side is a field, a quoted string a string. - **`null` stays `null`.** `n + 1` over a row with no `n` is `null`, as in SQL; write `coalesce(n, 0) + 1` to count from nothing. - **Every write answers its count** — `{"affected": n}` over HTTP, the `count` of the browser module's answer, and the write result of every SDK. How set reads the row, and its types, precedence and cost **Types.** The result goes through the field's type check as a literal would. Two `int`s make an `int`, and a result past 64 bits is refused, never wrapped; `/` between `int`s divides whole, toward zero, as PostgreSQL's does (`7 / 2` is `3`), and a division by zero is refused. An `int` and a `float` make a `float`, which an `int` field takes when it is whole and refuses when it is not — `n * 2.0` fits, `n + 0.5` does not, as the literal `2.5` does not. A float that is no longer finite is refused. A `timestamp` plus or minus an `int` is a timestamp that many milliseconds on (`now() + 30000`), and two timestamps apart are the milliseconds between them. `+` joins two texts (`$1 + ":dr"`), and only two texts. Anything else — text and a number, either way round, or `-` between texts — is a type error: a number turned into text without asking would hide the mistake a typed field is there to catch. An error refuses the statement whole, a block of one, so nothing of it is written; over no rows there is no error, as there is no row to work it out over. **Precedence.** `*` and `/` bind tighter than `+` and `-`, and all four tighter than a comparison: `where n + 1 > 2 * m`. `-1` where a value starts is a number; after a value, `n-1` and `n - 1` are a subtraction. **Time.** `now()` is worked out once a statement, so every row a `set` writes holds the same time. In the browser it is the time the page hands the module with every statement, which has no clock of its own. **What travels.** The record holds the document as written, the value worked out, never the expression: a replica, `/_changes`, an archive and a subscriber see the result, and a restore replays it as it was. A scoped token's rules test the document a `set` writes, as they test a literal's ([Scoped access](https://fenecdb.com/docs/http-access#scoped-access)). A replica of [Sync](https://fenecdb.com/docs/sync) works the expression out over its own row at once and sends the statement, which the server works out again over its own: two replicas' offline increments both land, and both replicas come to the server's sum. **How it runs.** The pairs are bound to the collection once a statement, as a filter is: each field written and read by its position, and a value that reads no field — a literal, a parameter, `now()` — worked out once rather than a row. Each row is read once: a `set` read it twice before, once for the values and once for the indexes it takes out. **`if absent`** passes over a document whose `id`, or whose `@unique` value, a live row holds, where `insert` refuses the statement: the others are written, and the answer counts those alone. That is Redis's `SET NX` in one statement, and it neither aborts a `/batch` nor makes an error a client has to catch. A row past its `@ttl` holds nothing — it is out of every read — so a write takes its place. **What it costs, measured** (`make counters-bench`, in process over 100 000 rows of a text, an int and a note). A `set` of a constant over every row took 65.0 ms against 85.5 before expressions — the row read once instead of twice — and `{n: n + 1}` 66.6 ms; one row by id 1.07 us against 1.26, and 1.18 for `n + 1`. Sixteen threads incrementing one key 10 000 times each ended at 160 000, 320 000 increments a second, and sixteen HTTP clients the same against `fenec-server`, 102 000 a second. ## Writes that must find their rows A write that matches no row is no error: it answers `{"affected": 0}`, and a `/batch` goes on to the next statement. `require N` after a `set`, a `del`, a `put` or an `insert` makes the count a condition: unless the statement wrote exactly `N` rows it is refused, and the block it is in — a `/batch`, a run of several in the page, a keyed write — is put back whole, as on any other error. ```fenecql set accounts {balance: balance - $1} where name = $2 and balance >= $1 require 1 del holds where order_ref = $1 and state = "held" require 1 put locks {name: "nightly", owner: $1} if absent require 1 -- refused while it is held ``` - **Refused over HTTP with 412**, the error naming the statement: ``set accounts` wrote 0 rows, and requires 1`. A `/batch` also says which statement stopped it (`"at"`, from 0). Every SDK has its own kind for it (`unmet`), and every builder an option: `.update(patch, { require: 1 })`. - **The count is the write's own**, taken under the write lock that made it, so nothing can come between the check and the write. A scoped token's count is of the rows its rules let it write. - **`require 0`** asserts that nothing matches. ### A read that must find its rows `require N` after a `get` guards a read the same way: unless the `get` answers exactly `N` rows it is refused (412, `"at"` naming it) and its batch put back. In a `/batch` it reads under the batch's write lock, after the statements before it and before those after, so what it saw is what the writes around it see: "the price is still 12", "the coupon is still good". ```fenecql get products where sku = $1 and price = $2 and stock > 0 require 1 get coupons where code = $1 and uses < max_uses limit 1 require 1 -- one is enough get holds where order_ref = $1 and state = "held" require 0 -- none may be held ``` ```js await db.from('products') .where('sku', 'tee') .where('price', 1200) .where('stock', '>', 0) .require(1) .rows(); await db.from('coupons') .where('code', 'X1') .where(raw('uses < max_uses')) .limit(1) .require(1) .rows(); await db.from('holds') .where('order_ref', 'o-17') .where('state', 'held') .require(0) .rows(); ``` ```python (db.collection("products") .where("sku", "tee") .where("price", 1200) .where("stock", ">", 0) .require(1) .rows()) (db.collection("coupons") .where("code", "X1") .where(raw("uses < max_uses")) .limit(1) .require(1) .rows()) (db.collection("holds") .where("order_ref", "o-17") .where("state", "held") .require(0) .rows()) ``` ```go db.From("products"). Where("sku", "=", "tee"). Where("price", "=", 1200). Where("stock", ">", 0). Require(1). Rows(ctx) db.From("coupons"). Where("code", "=", "X1"). WhereCond(fenecdb.Raw("uses < max_uses")). Limit(1). Require(1). Rows(ctx) db.From("holds"). Where("order_ref", "=", "o-17"). Where("state", "=", "held"). Require(0). Rows(ctx) ``` ```csharp await db.From("products") .Where("sku", "tee") .Where("price", 1200) .Where("stock", ">", 0) .Require(1) .RowsAsync(); await db.From("coupons") .Where("code", "X1") .Where(Cond.Raw("uses < max_uses")) .Limit(1) .Require(1) .RowsAsync(); await db.From("holds") .Where("order_ref", "o-17") .Where("state", "held") .Require(0) .RowsAsync(); ``` ```swift try await db.from("products") .where("sku", "tee") .where("price", 1200) .where("stock", ">", 0) .require(1) .rows() try await db.from("coupons") .where("code", "X1") .where(.raw("uses < max_uses")) .limit(1) .require(1) .rows() try await db.from("holds") .where("order_ref", "o-17") .where("state", "held") .require(0) .rows() ``` ```kotlin db.from("products") .where("sku", "tee") .where("price", 1200) .where("stock", ">", 0) .require(1) .rows() db.from("coupons") .where("code", "X1") .where(Cond.raw("uses < max_uses")) .limit(1) .require(1) .rows() db.from("holds") .where("order_ref", "o-17") .where("state", "held") .require(0) .rows() ``` ```dart await db.from('products') .where('sku', 'tee') .where('price', 1200) .where('stock', '>', 0) .require(1) .rows(); await db.from('coupons') .where('code', 'X1') .where(Cond.raw('uses < max_uses')) .limit(1) .require(1) .rows(); await db.from('holds') .where('order_ref', 'o-17') .where('state', 'held') .require(0) .rows(); ``` ```fenecql get products where sku = $1 and price = $2 and stock > $3 require 1 -- ["tee", 1200, 0] get coupons where code = $1 and uses < max_uses limit $2 require 1 -- ["X1", 1] get holds where order_ref = $1 and state = $2 require 0 -- ["o-17", "held"] ``` - **It counts the rows the `get` answers**, after `offset` and `limit`: `limit 1 require 1` asks that one exists, and reads no further. A `lookup`'s children are not counted, and `require` goes before the `lookup`, whose clauses are the children's. - **Not with `count`** or an aggregate without `group`, which answer one row whatever matched; over groups it counts the groups. An inner `get` of `in (get …)` takes none. - **On its own** it is a read like any other, refused with 412 when the count is not met. A scoped token's count is of the rows it may read. - **Every builder** has it as a step, `.require(1)`; a write takes it as its option instead. A replica's sync refuses one in a batch of synced writes: it would count the replica's rows, and the server, which lands the batch, would never see it — send a guarded batch to the server itself. ### Transfers Moving money between two accounts is a debit where the money is, a credit where the account is, and a journal of both, as one `/batch`. Without `require` an overdraft's debit matched nothing, answered 0, and the credit went ahead: the sum of the balances grew with every refused transfer. With it, a debit that finds too little money or a credit that finds no account puts the whole transfer back, journal and all: ```fenecql create collection accounts (name text @unique, balance int) create collection journal (tx text @hash, account text @hash, amount int) set accounts {balance: balance - $3} where name = $1 and balance >= $3 require 1 set accounts {balance: balance + $3} where name = $2 require 1 insert journal [{tx: $4, account: $1, amount: 0 - $3}, {tx: $4, account: $2, amount: $3}] ``` ```bash printf '%s\n' \ '{"query":"set accounts {balance: balance - $3} where name = $1 and balance >= $3 require 1","params":["ada","bob",30,"t1"]}' \ '{"query":"set accounts {balance: balance + $3} where name = $2 require 1","params":["ada","bob",30,"t1"]}' \ '{"query":"insert journal [{tx: $4, account: $1, amount: 0 - $3}, {tx: $4, account: $2, amount: $3}]","params":["ada","bob",30,"t1"]}' | curl -s -X POST localhost:8080/batch -H "Idempotency-Key: t1" --data-binary @- # an overdraft: 412 {"error":"unmet: `set accounts` wrote 0 rows, and requires 1","completed":0,"at":0} ``` - **Amounts are whole minor units** in an `int`, so a sum is exact. - **An `Idempotency-Key`** makes a retry after a timeout answer what the first try did, rather than move the money twice ([Retrying a write](https://fenecdb.com/docs/http#idempotency)). - **Who may move what is the app's.** The credit is another user's row, which a scoped token may not write: transfers go through an app server holding the server's token. - **A read can guard it too.** A client that showed a balance and moves money only while it is still that puts `get accounts where name = $1 and balance = $5 require 1` first; changed meanwhile, the transfer stops there, 412 with `"at":0`. - **Held to it:** eight threads making 4 800 random transfers among six accounts in process, and eight clients making 2 400 as `/batch`es over HTTP, an eighth of them to an account that is not there, leave the sum where it started, no balance below zero, and a journal that accounts for every balance (`crates/fenec-core/tests/require.rs`, `crates/fenec-http/tests/require.rs`). - **A whole ledger** built this way — refunds, holds, an append-only journal, reconciliation, a change-stream sink, scoped tokens — is [examples/ledger](https://github.com/fenecdb/fenecdb/tree/main/examples/ledger). ### Checkout An order is placed at the price the shopper saw, against stock that is there, with a coupon that is still good — or not at all. Each condition is a `require`: the price and the coupon read, the stock taken, the coupon's use counted and the order written, as one `/batch`. ```fenecql create collection products (sku text @unique, price int, stock int) create collection coupons (code text @unique, off int, uses int, max_uses int) create collection orders (ref text @unique, sku text @hash, paid int) get products where sku = $1 and price = $2 require 1 get coupons where code = $3 and uses < max_uses require 1 set products {stock: stock - 1} where sku = $1 and stock > 0 require 1 set coupons {uses: uses + 1} where code = $3 require 1 insert orders {ref: $4, sku: $1, paid: $2 - $5} ``` - **A price that moved** stops the batch at its first line, 412 with `"at":0`: the shopper is shown the new price, and nothing was written. - **A coupon used up** stops it at the second, **no stock** at the third; the order's `@unique` `ref` refuses the same checkout twice (409), and an `Idempotency-Key` answers a retry what the first try did. - **Under the write lock**, every line sees the ones before it: two shoppers taking the last one cannot both pass the stock line. ## Writes that pick their rows `order` and `limit` after a `set` or a `del` pick the rows it writes: the page a `get` with the same `where`, `order` and `limit` would answer, found and written under the one writer's lock. `returning` answers them rather than their count — a `set`'s rows as it wrote them, a `del`'s as they were, in the order they were picked. A job queue's claim is one statement: ```fenecql create collection jobs (payload json, run_at timestamp @sorted, owner text, attempts int) ``` ```fenecql put jobs [{payload: {to: "ada"}, run_at: now(), attempts: 0}, {payload: {to: "bo"}, run_at: now(), attempts: 0}] set jobs {owner: "w1", run_at: now() + 30000, attempts: attempts + 1} where run_at <= now() order run_at asc limit 10 returning * del jobs where id = 1 and owner = "w1" require 1 del jobs where run_at <= now() order run_at asc limit 10 returning id, payload.to ``` ```js await db.from('jobs') .insert([{ payload: { to: 'ada' }, run_at: expr('now()'), attempts: 0 }, { payload: { to: 'bo' }, run_at: expr('now()'), attempts: 0 }]); await db.from('jobs') .where(raw('run_at <= now()')) .order('run_at') .limit(10) .update( { owner: 'w1', run_at: expr('now() + ?', 30000), attempts: expr('attempts + 1') }, { returning: true } ); await db.from('jobs') .where('id', 1) .where('owner', 'w1') .delete({ require: 1 }); await db.from('jobs') .where(raw('run_at <= now()')) .order('run_at') .limit(10) .delete({ returning: ['id', 'payload.to'] }); ``` ```python (db.collection("jobs") .insert([{"payload": {"to": "ada"}, "run_at": expr("now()"), "attempts": 0}, {"payload": {"to": "bo"}, "run_at": expr("now()"), "attempts": 0}])) (db.collection("jobs") .where(raw("run_at <= now()")) .order("run_at") .limit(10) .update( {"owner": "w1", "run_at": expr("now() + ?", 30000), "attempts": expr("attempts + 1")}, returning=True )) (db.collection("jobs") .where("id", 1) .where("owner", "w1") .delete(require=1)) (db.collection("jobs") .where(raw("run_at <= now()")) .order("run_at") .limit(10) .delete(returning=["id", "payload.to"])) ``` ```go db.From("jobs"). Insert( ctx, fenecdb.D("payload", fenecdb.D("to", "ada"), "run_at", fenecdb.Expr("now()"), "attempts", 0), fenecdb.D("payload", fenecdb.D("to", "bo"), "run_at", fenecdb.Expr("now()"), "attempts", 0), ) db.From("jobs"). WhereCond(fenecdb.Raw("run_at <= now()")). Order("run_at", "asc"). Limit(10). Update( ctx, fenecdb.D("owner", "w1", "run_at", fenecdb.Expr("now() + ?", 30000), "attempts", fenecdb.Expr("attempts + 1")), fenecdb.Returning("*"), ) db.From("jobs"). Where("id", "=", 1). Where("owner", "=", "w1"). Delete(ctx, fenecdb.Require(1)) db.From("jobs"). WhereCond(fenecdb.Raw("run_at <= now()")). Order("run_at", "asc"). Limit(10). Delete(ctx, fenecdb.Returning("id", "payload.to")) ``` ```csharp await db.From("jobs") .InsertAsync(new object[] { new { payload = new { to = "ada" }, run_at = Computed.Expr("now()"), attempts = 0 }, new { payload = new { to = "bo" }, run_at = Computed.Expr("now()"), attempts = 0 } }); await db.From("jobs") .Where(Cond.Raw("run_at <= now()")) .Order("run_at") .Limit(10) .UpdateAsync( new { owner = "w1", run_at = Computed.Expr("now() + ?", 30000), attempts = Computed.Expr("attempts + 1") }, returning: ["*"] ); await db.From("jobs") .Where("id", 1) .Where("owner", "w1") .DeleteAsync(require: 1); await db.From("jobs") .Where(Cond.Raw("run_at <= now()")) .Order("run_at") .Limit(10) .DeleteAsync(returning: ["id", "payload.to"]); ``` ```swift try await db.from("jobs") .insert([["payload": ["to": "ada"], "run_at": .expr("now()"), "attempts": 0], ["payload": ["to": "bo"], "run_at": .expr("now()"), "attempts": 0]] as Value) try await db.from("jobs") .where(.raw("run_at <= now()")) .order("run_at") .limit(10) .updateReturning(["owner": "w1", "run_at": .expr("now() + ?", 30000), "attempts": .expr("attempts + 1")] as Value) try await db.from("jobs") .where("id", 1) .where("owner", "w1") .delete(require: 1) try await db.from("jobs") .where(.raw("run_at <= now()")) .order("run_at") .limit(10) .deleteReturning(returning: ["id", "payload.to"]) ``` ```kotlin db.from("jobs") .insert(listOf(mapOf("payload" to mapOf("to" to "ada"), "run_at" to Computed.expr("now()"), "attempts" to 0), mapOf("payload" to mapOf("to" to "bo"), "run_at" to Computed.expr("now()"), "attempts" to 0))) db.from("jobs") .where(Cond.raw("run_at <= now()")) .order("run_at") .limit(10) .updateReturning(mapOf("owner" to "w1", "run_at" to Computed.expr("now() + ?", 30000), "attempts" to Computed.expr("attempts + 1"))) db.from("jobs").where("id", 1).where("owner", "w1").delete(require = 1) db.from("jobs") .where(Cond.raw("run_at <= now()")) .order("run_at") .limit(10) .deleteReturning(returning = listOf("id", "payload.to")) ``` ```dart await db.from('jobs') .insert([{'payload': {'to': 'ada'}, 'run_at': Computed.expr('now()'), 'attempts': 0}, {'payload': {'to': 'bo'}, 'run_at': Computed.expr('now()'), 'attempts': 0}]); await db.from('jobs') .where(Cond.raw('run_at <= now()')) .order('run_at') .limit(10) .updateReturning({'owner': 'w1', 'run_at': Computed.expr('now() + ?', [30000]), 'attempts': Computed.expr('attempts + 1')}); await db.from('jobs') .where('id', 1) .where('owner', 'w1') .delete(require: 1); await db.from('jobs') .where(Cond.raw('run_at <= now()')) .order('run_at') .limit(10) .deleteReturning(returning: ['id', 'payload.to']); ``` ```fenecql put jobs [{payload: $1, run_at: now(), attempts: $2}, {payload: $3, run_at: now(), attempts: $4}] -- [{"to": "ada"}, 0, {"to": "bo"}, 0] set jobs {owner: $1, run_at: now() + $2, attempts: attempts + 1} where run_at <= now() order run_at asc limit $3 returning * -- ["w1", 30000, 10] del jobs where id = $1 and owner = $2 require 1 -- [1, "w1"] del jobs where run_at <= now() order run_at asc limit $1 returning id, payload.to -- [10] ``` - **The page is a `get`'s.** `order` over an `@sorted` field walks its index and stops at the page; `limit` without `order` takes the first matches by `id`; `order` without `limit` writes every match, in that order. `offset` is a read's alone. - **Two writers never pick the same row.** The rows are found and written under the write lock, where PostgreSQL's `UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING *` locks rows and skips those another holds. A client that reads the page and then writes it loses the race whenever another read the same page. - **`returning *`** answers the id and every field, as `select *` does; a list answers those named, paths among them. Over HTTP the answer is the rows, as a `get`'s. - **`require N`** counts the rows answered: `limit 1 require 1` is one row or a refusal. - **A scoped token** picks among the rows its rules let it update or delete, and what it answers is held to its read rules as well — a `set`'s rows as it writes them, so a worker that reads only the jobs it holds is answered the job it claims, and a `set` that would leave a row it may not read is refused whole (403). Every row it writes is held to its rules after the write, its fields to an `update(…)` grant's and its change to a transition's ([Scoped access](https://fenecdb.com/docs/http-access#scoped-access)). A row answered shows the fields of the read rules admitting it, and a value, a `where` or an `order` reads a field a rule withholds only as a read may ([Fields a read shows](https://fenecdb.com/docs/http-access#field-grants)). - **A sync replica refuses one** on a synced collection: it would pick and answer the replica's rows, and the server, sent the text, its own. - **Every builder** writes it: `order` and `limit` before an `update` or a `delete`, and `{ returning: true }` — or the fields — as its option. See [Job queues](https://fenecdb.com/docs/queues) for the whole queue: ack, extend, backoff, dead letters, priorities. - **An `order` key may be an expression**, so the nearest ready job is one claim too: `set jobs {owner: $1} where run_at <= now() order distance(pickup, $2) limit 1 returning *` ([Ordering by an expression](https://fenecdb.com/docs/fenecql-reading#order-by-expression)). A write takes no `near`: the distance is a key of `order` there, or an inner `get`'s `near` picks the id — `where id in (get jobs select id where run_at <= now() near pickup $2 limit 1)`, run first under the same lock. ### The rows an insert made `returning` after a `put` or an `insert` answers the rows it wrote rather than their count, ids and all, in the order of its documents — what a client needs to know the id of the row it made, without reading it back by a key of its own: ```fenecql create collection orders (item text, customer text, n int) insert orders {item: "tea", customer: "ada"} returning id insert orders [{item: "pie", customer: "ada"}, {item: "jam", customer: "bo", n: 2}] returning * put orders {item: "tea", customer: "ada"} if absent else set {n: coalesce(n, 0) + 1} returning id, n ``` - **Each row as the statement left it**: a row `if absent` passed over is not answered, the row `else set` set is, and a document written twice in one statement is answered twice, as it ended. `require N` counts the rows answered. - **Over HTTP** the answer is the rows, with the change it left the database at in `Fenec-Seq`; `POST /orders?returning=id` answers a REST insert's rows (201) where it otherwise answers `{"inserted": n}`. - **A scoped token** is answered only rows its read rules let it read, as a `set`'s are: each row written is held to them as written — a row made as an insert, a row set as an update — and one they leave out refuses the write, put back whole (403). An append to a stream the token may write and not read is written without `returning`. - **Every builder** takes it: `insert(docs, { returning: ['id'] })` and `upsert(docs, patch, { returning: true })`, Python's `returning=`, Go's `Returning(...)` among the documents, .NET's `returning:`, Swift's, Kotlin's and Dart's `insertReturning` and `upsertReturning`. A sync replica refuses it on a synced collection, as a claim: its ids are the replica's until the server answers. ## Bulk loading For a bulk load, build the index afterwards. The write path becomes a pure append and the graph is built in one parallel pass. ```fenecql create collection docs (title text, embed vector<768>) put docs [ ... 100 000 documents ... ] create index on docs (embed) @hnsw(cosine) ``` ## Update and delete ```fenecql set articles {year: 2025} where id = 42 set articles {views: coalesce(views, 0) + 1} where id = 42 -- an increment del articles where year < 2000 ``` ```js await db.from('articles').where('id', 42).update({ year: 2025 }); await db.from('articles').where('id', 42).update({ views: inc(1) }); await db.from('articles').where('year', '<', 2000).delete(); ``` ```python db.collection("articles").where("id", 42).update({"year": 2025}) db.collection("articles").where("id", 42).update({"views": inc(1)}) db.collection("articles").where("year", "<", 2000).delete() ``` ```go db.From("articles"). Where("id", "=", 42). Update(ctx, fenecdb.D("year", 2025)) db.From("articles"). Where("id", "=", 42). Update(ctx, fenecdb.D("views", fenecdb.Inc(1))) db.From("articles").Where("year", "<", 2000).Delete(ctx) ``` ```csharp await db.From("articles") .Where("id", 42) .UpdateAsync(new { year = 2025 }); await db.From("articles") .Where("id", 42) .UpdateAsync(new { views = Computed.Inc(1) }); await db.From("articles").Where("year", "<", 2000).DeleteAsync(); ``` ```swift try await db.from("articles") .where("id", 42) .update(["year": 2025] as Value) try await db.from("articles") .where("id", 42) .update(["views": .inc(1)] as Value) try await db.from("articles").where("year", "<", 2000).delete() ``` ```kotlin db.from("articles").where("id", 42).update(mapOf("year" to 2025)) db.from("articles") .where("id", 42) .update(mapOf("views" to Computed.inc(1))) db.from("articles").where("year", "<", 2000).delete() ``` ```dart await db.from('articles').where('id', 42).update({'year': 2025}); await db.from('articles') .where('id', 42) .update({'views': Computed.inc(1)}); await db.from('articles').where('year', '<', 2000).delete(); ``` ```fenecql set articles {year: $1} where id = $2 -- [2025, 42] set articles {views: coalesce(views, 0) + $1} where id = $2 -- [1, 42] del articles where year < $1 -- [2000] ``` --- Source: https://fenecdb.com/docs/fenecql-schema # Collections and types What a collection declares and what a field holds: json and paths, points, time, collation and case. A collection declares its fields and their types, and the type decides what a field holds, how it compares and which indexes it takes: `bool`, `int`, `float`, `text`, `bytes`, `timestamp`, lists such as `[text]`, `json`, `geo` and the vectors. Run in [the shell](https://fenecdb.com/docs/shell#quickstart), answers and all; the timings are from one run. ```text fenec> create collection people (name text collate tr @sorted, email text nocase @unique, meta json); collection `people` created fenec> put people [{name: "Zeynep", email: "Zeynep@Example.com", meta: {plan: "pro", seats: 3}}, {name: "Çağla", email: "cagla@example.com", meta: {plan: "free", seats: 1}}, {name: "İlker", email: "ilker@example.com", meta: {plan: "free", seats: 1}}, {name: "Irmak", email: "irmak@example.com", meta: {plan: "free", seats: 1}}, {name: "Cem", email: "cem@example.com", meta: {plan: "pro", seats: 5}}]; 5 records (1.43ms) fenec> describe people people name text collate tr @sorted email text nocase @unique meta json fenec> get people select name order name name ------ Cem Çağla Irmak İlker Zeynep (5 rows, 112.33µs) fenec> get people select email where email = "ZEYNEP@example.com" email ------------------ Zeynep@Example.com (1 rows, 148.46µs) fenec> get people select name, meta.plan where meta.seats >= 3 order meta.seats desc name meta.plan ------ --------- Cem pro Zeynep pro (2 rows, 138.71µs) fenec> alter collection people add field city text; field `city` added to `people` fenec> get people select name, city where name = "Irmak" name city ----- ---- Irmak - (1 rows, 125.58µs) ``` `describe` shows the schema the file holds, and the shell prints a null as `-`. The names come out in Turkish order because the field says `collate tr` ([Collation](#collation)); the email compares by its case fold because it is `nocase` ([Case-insensitive text](#nocase)); `meta.plan` is a path into a `json` field ([Objects and paths](#json)); and the field added last reads null in every row written before it, with no document rewritten ([Changing a collection](#changing-a-collection)). The grammar of `create collection` is under [Statements](https://fenecdb.com/docs/fenecql#statements). ## Objects and paths A `json` field holds any value JSON does — an object, a list, a number, text, `true`, `false` or `null` — with no type declared for what is inside. A path reads into it wherever a field is read. ```fenecql create collection docs (title text, meta json) ``` ```fenecql put docs {title: "a", meta: {lang: "tr", source: {site: "x", rank: 3}}} get docs where meta.lang = "tr" and meta.source.rank >= 2 get docs select title, meta.source.site order meta.source.rank desc set docs {meta.lang: "en"} where title = "a" ``` ```js await db.from('docs') .insert({ title: 'a', meta: { lang: 'tr', source: { site: 'x', rank: 3 } } }); await db.from('docs') .where('meta.lang', 'tr') .where('meta.source.rank', '>=', 2) .rows(); await db.from('docs') .select('title', 'meta.source.site') .order('meta.source.rank', 'desc') .rows(); await db.from('docs').where('title', 'a').update({ 'meta.lang': 'en' }); ``` ```python (db.collection("docs") .insert({"title": "a", "meta": {"lang": "tr", "source": {"site": "x", "rank": 3}}})) (db.collection("docs") .where("meta.lang", "tr") .where("meta.source.rank", ">=", 2) .rows()) (db.collection("docs") .select("title", "meta.source.site") .order("meta.source.rank", "desc") .rows()) db.collection("docs").where("title", "a").update({"meta.lang": "en"}) ``` ```go db.From("docs"). Insert( ctx, fenecdb.D("title", "a", "meta", fenecdb.D("lang", "tr", "source", fenecdb.D("site", "x", "rank", 3))), ) db.From("docs"). Where("meta.lang", "=", "tr"). Where("meta.source.rank", ">=", 2). Rows(ctx) db.From("docs"). Select("title", "meta.source.site"). Order("meta.source.rank", "desc"). Rows(ctx) db.From("docs"). Where("title", "=", "a"). Update(ctx, fenecdb.D("meta.lang", "en")) ``` ```csharp await db.From("docs") .InsertAsync(new { title = "a", meta = new { lang = "tr", source = new { site = "x", rank = 3 } } }); await db.From("docs") .Where("meta.lang", "tr") .Where("meta.source.rank", ">=", 2) .RowsAsync(); await db.From("docs") .Select("title", "meta.source.site") .Order("meta.source.rank", "desc") .RowsAsync(); await db.From("docs") .Where("title", "a") .UpdateAsync(new Dictionary { ["meta.lang"] = "en" }); ``` ```swift try await db.from("docs") .insert(["title": "a", "meta": ["lang": "tr", "source": ["site": "x", "rank": 3]]] as Value) try await db.from("docs") .where("meta.lang", "tr") .where("meta.source.rank", ">=", 2) .rows() try await db.from("docs") .select("title", "meta.source.site") .order("meta.source.rank", "desc") .rows() try await db.from("docs") .where("title", "a") .update(["meta.lang": "en"] as Value) ``` ```kotlin db.from("docs") .insert(mapOf("title" to "a", "meta" to mapOf("lang" to "tr", "source" to mapOf("site" to "x", "rank" to 3)))) db.from("docs") .where("meta.lang", "tr") .where("meta.source.rank", ">=", 2) .rows() db.from("docs") .select("title", "meta.source.site") .order("meta.source.rank", "desc") .rows() db.from("docs").where("title", "a").update(mapOf("meta.lang" to "en")) ``` ```dart await db.from('docs') .insert({'title': 'a', 'meta': {'lang': 'tr', 'source': {'site': 'x', 'rank': 3}}}); await db.from('docs') .where('meta.lang', 'tr') .where('meta.source.rank', '>=', 2) .rows(); await db.from('docs') .select(['title', 'meta.source.site']) .order('meta.source.rank', 'desc') .rows(); await db.from('docs').where('title', 'a').update({'meta.lang': 'en'}); ``` ```fenecql put docs {title: $1, meta: $2} -- ["a", {"lang": "tr", "source": {"site": "x", "rank": 3}}] get docs where meta.lang = $1 and meta.source.rank >= $2 -- ["tr", 2] get docs select title, meta.source.site order meta.source.rank desc set docs {meta.lang: $1} where title = $2 -- ["en", "a"] ``` ```fenecql create index on docs (meta.lang) @hash create index on docs (meta.source.rank) @sorted ``` - **A path that leads nowhere is `null`**: a key a document does not have, or a value on the way that is not an object. - **A path takes `@hash`, `@unique` and `@sorted`**, and they answer what the scan would, row for row. - **`set` with a path changes one key** and keeps the rest of the object; it makes the object where there is none. - **Typed fields are unchanged.** An `int` or a `text` field still refuses an object. How objects keep their keys and numbers, and what paths cost An object's keys are kept sorted and unique: a key given twice — in FenecQL, in a JSON body, in a parameter — refuses the write rather than keep one of them, since which one a reader keeps differs from reader to reader. Its numbers are kept as written, an integer an integer and `19.99` the decimal it is, everywhere one comes in: FenecQL, a parameter over HTTP and in the browser, a JSON body, a row `fenec import` reads. A list of numbers alone is read the quick way, into the `f32`s of the vector it nearly always is; where it is given a `json` field, or compared with a path into one, the statement's text or the parameter's JSON is read again as written, so `[0.1, 12345678901]` is kept as those two numbers. A vector that was never text — a `Float32Array` handed to the browser module, a vector built through the Rust API — is refused there rather than kept as numbers it was not given. A timestamp becomes its ISO text, as JSON writes one; bytes, a sparse vector and a number that is not finite have no JSON and are refused. A value nests at most 64 levels, and a path names at most 64 keys past its field: past either a write or a query is refused, never cut. A path is a field's name, a dot, and keys, each a name as a field's is (`meta.source.rank`); a key with a space or a dash in it cannot be named, and is reached by reading the whole field. It works in every comparison, `in`, `has`, `~` and `is null`, in `select` — answering under the path's text as its column's name — in `order`, and in `set` and `put`, which set the one key inside the field. A filter binds a path once a query, as it binds a field, and walks its keys over each row's bytes, passing the members before the key without decoding them. An aggregate and `group` read a field, not a path; a `lookup`'s keys are fields too. Comparisons are the ones every value already has: `3` equals `3.0`, every number orders below every text, text below a list and a list below an object, and `null` is equal only to `null`. An index on a path is a field of its own beside the fields, named by the path, and its kind is the field's: `@hash` keys a value as a typed field's does, a whole float filed as the int it equals so that `3` and `3.0` share a bucket; an equality or an `in` reaches it with null, a boolean, text and a number an `f64` holds exactly, and anything else — an int past 253, which equals floats near it that no bucket holds — is answered by the scan. `@unique` refuses a value held twice as a field's does, `3` and `3.0` the same value. `@sorted` orders the numbers as `f64`s and the text by its bytes, every number before every text; a value it has no place for — `true` or `false`, a list, an object, an int past 253 — is held apart, and while the index holds one it answers nothing and the scan does: a wrong order is never what an index costs. `tests/json.rs` holds both to a twin collection without them, every filter, order and page, through writes and a reopen. Text, vector and sparse indexes stay on the fields that declare their type. On a server an index on a path is built under the write lock, as it reads one value a row: 11 ms for `@hash` and 21 for `@sorted` over 100 000 documents. Renaming a `json` field renames the paths into it, indexes and all, and dropping it drops them. In the file a `json` field is type tag 14 and an object value tag 13 — not 11 or 12, which a schema writes for a collation and a dropped place — and each index on a path is written inside its field's entry, its keys and its kind: a binary from before them refuses the file (unknown type tag 14) rather than read it. The indexes are derived, as every hash and ordered index is: rebuilt from the documents by the first read after an open. Measured over 100 000 documents of a `title`, a `year`, a `price` and a `meta` object of three keys: `where meta.lang = "tr"` scans in 5.7 ms where the same comparison of a text field takes 4.2; `meta.source.rank >= 990` in 7.2; both together in 10.4; the first 20 by `meta.source.rank` in 7.3. With `@hash` and `@sorted` on the two paths: 0.025, 0.009, 0.12 and 0.010 ms. A scan of a collection with no `json` field takes what it took before them (3.47 against 3.38 ms by two numbers, 4.17 against 4.21 by a text, 3.06 against 3.08 by `~`, 2.72 against 2.60 for an ordered page), and the browser module's filter 1.67 against 1.66. ## Points A `geo` field holds a point on the earth, `[lon, lat]` in degrees. `distance` measures in metres, `within` tests a box, and `near` over the field puts rows nearest first. The recipes, and what they cost against PostGIS and Redis, are in [Points and distances](https://fenecdb.com/docs/geo). ```fenecql create collection places (name text, loc geo @geo) put places {name: "Catania", loc: [15.087269, 37.502669]} get places select name, distance(loc, $1) as m where distance(loc, $1) <= 2000 get places where within(loc, [12, 37, 16, 39]) count get places select name near loc $1 limit 10 ``` - **`distance(a, b)`** is the haversine on a sphere of Redis's radius, 6 372 797.56 m: Redis's `GEODIST`. PostGIS's sphere, the earth's mean radius, gives 0.03% less. Either side is a point field or a point; a null on either side is null. - **`within(p, [west, south, east, north])`** holds a point on the box's edge; a west east of its east crosses the antimeridian. - **`near loc p`** orders by distance, a tie by id, with the metres as `_score`; `exact` measures every row, and `ef`, a graph's, is refused. - **`@geo`** narrows `distance(loc, p) <= r` (or `<`, either way round) and `within(loc, box)` in an `and` chain, and walks `near` nearest first. It is derived, as `@sorted` is: built by the first read after an open, nothing in the file but the field's declaration. - **A point is refused** past 180 degrees of longitude or 90 of latitude, and a list of other than two numbers. ## Time A `timestamp` field takes an ISO-8601 date or epoch milliseconds, and always comes back as ISO-8601 in UTC. ```fenecql put events {name: "login", t: "2026-09-19T12:34:56Z"} put events {name: "logout", t: 1758285296000} -- epoch ms get events where t >= "2026-01-01" and t < now() ``` ```js await db.from('events') .insert({ name: 'login', t: '2026-09-19T12:34:56Z' }); await db.from('events').insert({ name: 'logout', t: 1758285296000 }); await db.from('events') .where('t', '>=', '2026-01-01') .where(raw('t < now()')) .rows(); ``` ```python (db.collection("events") .insert({"name": "login", "t": "2026-09-19T12:34:56Z"})) db.collection("events").insert({"name": "logout", "t": 1758285296000}) (db.collection("events") .where("t", ">=", "2026-01-01") .where(raw("t < now()")) .rows()) ``` ```go db.From("events"). Insert(ctx, fenecdb.D("name", "login", "t", "2026-09-19T12:34:56Z")) db.From("events"). Insert(ctx, fenecdb.D("name", "logout", "t", 1758285296000)) db.From("events"). Where("t", ">=", "2026-01-01"). WhereCond(fenecdb.Raw("t < now()")). Rows(ctx) ``` ```csharp await db.From("events") .InsertAsync(new { name = "login", t = "2026-09-19T12:34:56Z" }); await db.From("events") .InsertAsync(new { name = "logout", t = 1758285296000 }); await db.From("events") .Where("t", ">=", "2026-01-01") .Where(Cond.Raw("t < now()")) .RowsAsync(); ``` ```swift try await db.from("events") .insert(["name": "login", "t": "2026-09-19T12:34:56Z"] as Value) try await db.from("events") .insert(["name": "logout", "t": 1758285296000] as Value) try await db.from("events") .where("t", ">=", "2026-01-01") .where(.raw("t < now()")) .rows() ``` ```kotlin db.from("events") .insert(mapOf("name" to "login", "t" to "2026-09-19T12:34:56Z")) db.from("events") .insert(mapOf("name" to "logout", "t" to 1758285296000)) db.from("events") .where("t", ">=", "2026-01-01") .where(Cond.raw("t < now()")) .rows() ``` ```dart await db.from('events') .insert({'name': 'login', 't': '2026-09-19T12:34:56Z'}); await db.from('events').insert({'name': 'logout', 't': 1758285296000}); await db.from('events') .where('t', '>=', '2026-01-01') .where(Cond.raw('t < now()')) .rows(); ``` ```fenecql put events {name: $1, t: $2} -- ["login", "2026-09-19T12:34:56Z"] put events {name: $1, t: $2} -- ["logout", 1758285296000] get events where t >= $1 and t < now() -- ["2026-01-01"] ``` Why timestamp is a type of its own `timestamp` holds UTC epoch milliseconds as an `i64`. Writes accept both text and numbers; a text literal in a comparison is parsed. The representation is always ISO-8601 (`2026-09-19T12:34:56.789Z`), in JSON and in FenecQL alike. The calendar conversion is integer arithmetic including leap-year and century rules — no table, no dependency. It exists as a separate type rather than an alias over `int` because `ResultSet` does not carry the schema, so an alias would show every client a raw number. ## Collation Text sorts by its bytes unless you ask otherwise, which is wrong in every language. `collate und` sorts it the way people expect in any language, and `collate tr` in Turkish order. ```fenecql get people order name collate und asc limit 20 get people order city collate tr desc, name collate tr asc get people select city, count(*) group city order city collate und asc ``` ```js await db.from('people') .order('name', 'asc', { collate: 'und' }) .limit(20) .rows(); await db.from('people') .order('city', 'desc', { collate: 'tr' }) .order('name', 'asc', { collate: 'tr' }) .rows(); await db.from('people') .select('city', 'count(*)') .group('city') .order('city', 'asc', { collate: 'und' }) .rows(); ``` ```python (db.collection("people") .order("name", "asc", collate="und") .limit(20) .rows()) (db.collection("people") .order("city", "desc", collate="tr") .order("name", "asc", collate="tr") .rows()) (db.collection("people") .select("city", "count(*)") .group("city") .order("city", "asc", collate="und") .rows()) ``` ```go db.From("people"). Order("name", "asc", fenecdb.Collate("und")). Limit(20). Rows(ctx) db.From("people"). Order("city", "desc", fenecdb.Collate("tr")). Order("name", "asc", fenecdb.Collate("tr")). Rows(ctx) db.From("people"). Select("city", "count(*)"). Group("city"). Order("city", "asc", fenecdb.Collate("und")). Rows(ctx) ``` ```csharp await db.From("people") .Order("name", "asc", collate: "und") .Limit(20) .RowsAsync(); await db.From("people") .Order("city", "desc", collate: "tr") .Order("name", "asc", collate: "tr") .RowsAsync(); await db.From("people") .Select("city", "count(*)") .Group("city") .Order("city", "asc", collate: "und") .RowsAsync(); ``` ```swift try await db.from("people") .order("name", "asc", collate: "und") .limit(20) .rows() try await db.from("people") .order("city", "desc", collate: "tr") .order("name", "asc", collate: "tr") .rows() try await db.from("people") .select("city", "count(*)") .group("city") .order("city", "asc", collate: "und") .rows() ``` ```kotlin db.from("people").order("name", "asc", collate = "und").limit(20).rows() db.from("people") .order("city", "desc", collate = "tr") .order("name", "asc", collate = "tr") .rows() db.from("people") .select("city", "count(*)") .group("city") .order("city", "asc", collate = "und") .rows() ``` ```dart await db.from('people').order('name', 'asc', 'und').limit(20).rows(); await db.from('people') .order('city', 'desc', 'tr') .order('name', 'asc', 'tr') .rows(); await db.from('people') .select(['city', 'count(*)']) .group('city') .order('city', 'asc', 'und') .rows(); ``` ```fenecql get people order name collate und asc limit $1 -- [20] get people order city collate tr desc, name collate tr asc get people select city, count(*) group city order city collate und asc ``` ```fenecql create collection people (name text collate und @sorted, city text) ``` ```fenecql get people where name > $1 order name asc limit 20 -- the page after $1 ``` ```js await db.from('people') .where('name', '>', 'Ada') .order('name') .limit(20) .rows(); ``` ```python (db.collection("people") .where("name", ">", "Ada") .order("name") .limit(20) .rows()) ``` ```go db.From("people"). Where("name", ">", "Ada"). Order("name", "asc"). Limit(20). Rows(ctx) ``` ```csharp await db.From("people") .Where("name", ">", "Ada") .Order("name") .Limit(20) .RowsAsync(); ``` ```swift try await db.from("people") .where("name", ">", "Ada") .order("name") .limit(20) .rows() ``` ```kotlin db.from("people") .where("name", ">", "Ada") .order("name") .limit(20) .rows() ``` ```dart await db.from('people') .where('name', '>', 'Ada') .order('name') .limit(20) .rows(); ``` ```fenecql get people where name > $1 order name asc limit $2 -- ["Ada", 20] ``` | `order name` | `order name collate und` | `order name collate tr` | | Irmak, Zeynep, cem, Çağla, İlker, ılgaz | Çağla, cem, İlker, Irmak, ılgaz, Zeynep | cem, Çağla, ılgaz, Irmak, İlker, Zeynep | | Zoë, anna, Ζωή, 山田, Борис, Émile | anna, Émile, Zoë, Ζωή, Борис, 山田 | anna, Émile, Zoë, Ζωή, Борис, 山田 | - **Name it on one key** in `order`, or **declare it on a field** so every comparison of that field uses it. - **Equality still compares bytes**, unless the field is `nocase` ([Case-insensitive text](#nocase)). - **In the browser**, data for scripts other than Latin is fetched when first needed. How collate und and tr order text, and what they cost and add to the module Text is ordered by its bytes unless a key says otherwise, and the bytes are wrong in every language: every capitalised word comes before every lowercase one, an accented letter lands after `z`, and every script past ASCII falls where its code points do. `collate und` orders the key as Unicode's root collation does — CLDR's, which PostgreSQL calls `und-x-icu` — the order every language's starts from: each script in a run of its own, its letters in their order, accents and case deciding only between words whose letters are the same. A language whose letters sort otherwise is a tailoring over it, and `collate tr` is Turkish, PostgreSQL's `tr-x-icu`: `ç` after `c`, `ğ` after `g`, `ı` before `i` with `I` as its capital and `İ` as the capital of `i`, `ö`, `ş` and `ü` after `o`, `s` and `u`. It compares a level at a time over the whole string, as ICU does: the letters, then their accents (`kar` < `kâr` < `kara`), then their case (`ince` < `İnce`), so an accent or a capital only decides between strings whose letters are the same. What all three leave equal goes by its bytes, as under a deterministic PostgreSQL collation — the order is total, and a page never changes with the plan. It applies wherever `order` does: the query's rows, a `lookup`'s children, an aggregate's groups by their key or a `min` or `max` of text. A key that holds no text — a number, `id`, `count(*)` — refuses it rather than ignore it. A field can be declared in a collation, `name text collate und`, and its text is in it wherever it compares: `order` without naming one, `<`, `<=`, `>` and `>=` in `where`, `min` and `max`, and a `@sorted` index over it, which holds that order and is walked for it. So a listing pages by the last name it showed — `where name > $last order name limit 20` — and the index answers as the scan does, row for row. Equality stays the bytes', since the collation ties no two different strings. The collation is the field's: it is written with the schema into the file, and `describe` shows it. A `@sorted` field without one keeps its bytes' order, so a key collated in the query is sorted rather than walked, and a comparison in `where` compares bytes. Over a million Turkish names (160 797 of them distinct) a comparison costs 33.5 ns in `tr` and 35.2 in `und` where the bytes cost 6.7, because it starts where two strings part — `Ahmet Yılmaz` against `Ahmet Kaya` is `Y` against `K` — which took it from 68 ns, and a Latin letter's weights are made once per collation and read, where looking each up through the tailoring and the root's tables took it to 41: | 1 000 000 names | bytes | `collate tr` | `collate und` | PostgreSQL, `"C"` | PostgreSQL, `"tr-x-icu"` | | a page, `limit 20` | 94.7 ms | 124 ms | 130 ms | 116 ms | 136 ms | | all of them in order | 374 ms | 890 ms | 925 ms | 506 ms | 1 208 ms | fenecdb in process, PostgreSQL 17 by its own execution time with the sort in memory; both on one core (`make collate-bench`). **The weights are ICU's own, for every assigned code point.** `tools/collate/gen.py` reads them out of ICU 76.1 into `crates/fenec-core/src/collate/`: the root's cut into chunks by script, and each tailoring as what it changes. Han ideographs sort in the radical-and-stroke order ICU's root gives them, those of the other planes among them; a Hangul syllable sorts as its jamo; and two or three characters ICU sorts as one sort as one — a letter and a mark it composes with (`c` and U+0327 is `ç`), a vowel sign written in two parts, a Thai or Lao vowel written before the consonant it follows in speech. An unassigned or private code point goes where ICU puts one. Checked against ICU itself over 1 250 000 random pairs in every script, of which five sort apart, and against `Intl.Collator("und")` over two thousand words in the browser module on every test run. What it does not do is ICU's normalisation: a mark that composes with its letter across another mark between them, or with marks out of their canonical order, sorts as a mark of its own — those five. ICU numbers every ideograph and most alphabets' letters one by one — 133 535 primary weights, which no packed entry has the bits for — so consecutive code points that alone give consecutive weights share a rank and are told apart by their code points, and a run of them is held as one range; a table's words are written as differences in LEB128 besides: 154 KB for every script, 324 KB plain, which a native build carries whole — 161 KB of the command line's 1040. The browser module carries the Latin chunk and fetches the others as a page first needs them, from `collate/` beside the module (JavaScript). A statement that would compare text of a script whose data it has not got is refused, the chunk named, rather than answered in another order; a write is refused before it changes anything, and an image whose collated text needs a chunk loads once it is there — a `@sorted` index built without it would be out of order. | chunk | bytes | gzip | brotli | holds | | `latin` | 9.0 KB | 4.9 KB | 4.2 KB | ASCII, Latin-1, Extended-A to -C, Vietnamese, punctuation, currency; in the module | | `greek`, `cyrillic` | 3.6, 2.1 KB | 1.6, 1.0 KB | 1.4, 0.9 KB | Greek with polytonic, Cyrillic | | `middle-east` | 7.3 KB | 3.6 KB | 3.3 KB | Armenian, Hebrew, Arabic, Syriac, Thaana, N'Ko | | `indic` | 3.0 KB | 1.6 KB | 1.5 KB | Devanagari to Sinhala | | `southeast-asia` | 8.5 KB | 2.2 KB | 2.2 KB | Thai, Lao, Tibetan, Myanmar, Khmer, Javanese … | | `east-asia` | 8.2 KB | 4.6 KB | 4.2 KB | Hangul, kana, Bopomofo, Yi, full-width forms | | `han`, `han-ext` | 31.1, 59.0 KB | 14.3, 27.3 KB | 12.7, 24.9 KB | ideographs of this plane, of planes 2 and 3 | | `symbols` | 6.9 KB | 2.8 KB | 2.6 KB | arrows, mathematics, emoji, mathematical letters | | `other`, `smp` | 5.7, 9.8 KB | 3.0, 5.0 KB | 2.8, 4.8 KB | every other script, of this plane and the next | The root and its chunks made the browser module 17.5 KB larger, 8.2 KB under gzip and 6.8 KB under brotli: the Latin chunk, the Turkish tailoring over it, and the code that reads them and has the others fetched. Every other chunk together is 145 KB, 67 KB gzip, 61 KB brotli. Their words are written as differences, which gzip and brotli take a third off: plain they were 102 and 90 KB. The Latin chunk alone comes out 0.3 KB larger under gzip that way, 0.9 KB under brotli, and 2.9 KB smaller in the module. ## Case-insensitive text: `nocase` A text field declared `nocase` compares by its case fold wherever it is compared, and keeps the text as it was written: an email under `@unique` takes `Alice@x.com` once, and finds it as `alice@X.COM`. It is PostgreSQL's `citext`, with Unicode's case folding where `citext` has `lower()`. ```fenecql create collection users (email text nocase @unique, name text collate tr nocase @sorted) ``` ```fenecql put users {email: "Alice@Example.com", name: "İsmail"} get users where email = "alice@example.COM" -- the row, its email as written put users {email: "ALICE@example.com"} -- refused: users.email is unique put users {email: "alice@example.com"} if absent -- passed over, and not counted get users where name = "ismail" -- the row: İ is the capital of i in Turkish get users select name order name asc limit 20 -- in Turkish order, case aside ``` ```js await db.from('users') .insert({ email: 'Alice@Example.com', name: 'İsmail' }); await db.from('users').where('email', 'alice@example.COM').rows(); await db.from('users').insert({ email: 'ALICE@example.com' }); await db.from('users') .insert({ email: 'alice@example.com' }, { ifAbsent: true }); await db.from('users').where('name', 'ismail').rows(); await db.from('users').select('name').order('name').limit(20).rows(); ``` ```python (db.collection("users") .insert({"email": "Alice@Example.com", "name": "İsmail"})) db.collection("users").where("email", "alice@example.COM").rows() db.collection("users").insert({"email": "ALICE@example.com"}) (db.collection("users") .insert({"email": "alice@example.com"}, if_absent=True)) db.collection("users").where("name", "ismail").rows() db.collection("users").select("name").order("name").limit(20).rows() ``` ```go db.From("users"). Insert(ctx, fenecdb.D("email", "Alice@Example.com", "name", "İsmail")) db.From("users").Where("email", "=", "alice@example.COM").Rows(ctx) db.From("users").Insert(ctx, fenecdb.D("email", "ALICE@example.com")) db.From("users"). Insert(ctx, fenecdb.D("email", "alice@example.com"), fenecdb.IfAbsent()) db.From("users").Where("name", "=", "ismail").Rows(ctx) db.From("users").Select("name").Order("name", "asc").Limit(20).Rows(ctx) ``` ```csharp await db.From("users") .InsertAsync(new { email = "Alice@Example.com", name = "İsmail" }); await db.From("users").Where("email", "alice@example.COM").RowsAsync(); await db.From("users").InsertAsync(new { email = "ALICE@example.com" }); await db.From("users") .InsertAsync(new { email = "alice@example.com" }, ifAbsent: true); await db.From("users").Where("name", "ismail").RowsAsync(); await db.From("users") .Select("name") .Order("name") .Limit(20) .RowsAsync(); ``` ```swift try await db.from("users") .insert(["email": "Alice@Example.com", "name": "İsmail"] as Value) try await db.from("users").where("email", "alice@example.COM").rows() try await db.from("users") .insert(["email": "ALICE@example.com"] as Value) try await db.from("users") .insert(["email": "alice@example.com"] as Value, ifAbsent: true) try await db.from("users").where("name", "ismail").rows() try await db.from("users").select("name").order("name").limit(20).rows() ``` ```kotlin db.from("users") .insert(mapOf("email" to "Alice@Example.com", "name" to "İsmail")) db.from("users").where("email", "alice@example.COM").rows() db.from("users").insert(mapOf("email" to "ALICE@example.com")) db.from("users") .insert(mapOf("email" to "alice@example.com"), ifAbsent = true) db.from("users").where("name", "ismail").rows() db.from("users").select("name").order("name").limit(20).rows() ``` ```dart await db.from('users') .insert({'email': 'Alice@Example.com', 'name': 'İsmail'}); await db.from('users').where('email', 'alice@example.COM').rows(); await db.from('users').insert({'email': 'ALICE@example.com'}); await db.from('users') .insert({'email': 'alice@example.com'}, ifAbsent: true); await db.from('users').where('name', 'ismail').rows(); await db.from('users').select(['name']).order('name').limit(20).rows(); ``` ```fenecql put users {email: $1, name: $2} -- ["Alice@Example.com", "İsmail"] get users where email = $1 -- ["alice@example.COM"] put users {email: $1} -- ["ALICE@example.com"] put users {email: $1} if absent -- ["alice@example.com"] get users where name = $1 -- ["ismail"] get users select name order name asc limit $1 -- [20] ``` - **Everything that compares it folds it:** `=`, `!=`, `in`, `<` and `>`, `~`; `@hash`, `@unique`, `@sorted` and an index over several fields; `order`, `group`, `count(distinct …)`, `facet`, `min` and `max`; a `lookup` on it; a scoped token's rules over it. - **It is kept as written.** A row reads back as it was put. A group or a facet value is one fold, shown as the first row holding it — in id order — wrote it. - **Unicode's full case folding:** `ß`, `ẞ`, `SS` and `ss` are one, a final `ς` is `σ`, `fi` is `fi`. In `collate tr`, Turkish's: `I` is the capital of `ı`, `İ` of `i`. - **Case alone.** Accents, width and how a text is composed are not case: `résumé` is not `resume`, `A` is `a` and not `A`, and `é` as one code point is not `e` and a combining accent. - **A text field's.** Not a list's or a `json` field's. Made `nocase` or no longer, a field is built again — the schema check's `rebuild` — which a `@unique` one refuses when two of its values would become one. How the case fold is made and held The fold is Unicode's full case folding, the `C` and `F` mappings of CaseFolding.txt, which Python's `str.casefold()` is too, and it is held to that at every code point (`crates/fenec-core/tests/nocase.rs` reads Python's own fold of each). Full rather than simple folding, which maps one code point to one: simple folding keeps `straße` apart from `STRASSE`, which ICU's case-insensitive comparison and every German reader find equal. A field in `collate tr` folds by Turkish's `T` mappings over it, as a Turkish reader expects: | | `nocase` | `collate tr nocase` | | `Alice`, `ALICE`, `alice` | one | one | | `straße`, `STRASSE`, `STRAẞE` | one | one | | `ΟΔΟΣ`, `οδος`, `οδοσ` | one | one | | `ᏣᎳᎩ`, `ꮳꮃꭹ` (Cherokee) | one | one | | `ISTANBUL`, `istanbul` | one | apart | | `ISTANBUL`, `ıstanbul` | apart | one | | `İstanbul`, `istanbul` | apart: `İ` folds to `i` and a combining dot, as its decomposed spelling does | one | | `ABC`, `abc` / `abc` | one / apart | one / apart | | `résumé`, `resume`; `é`, `e`+U+0301 | apart | apart | A `nocase` field in a collation orders by the collation over the folds — `collate tr nocase` puts `ılık` before `İnce` and finds `ince` equal to the second — so its order and its equality agree: a collation tells every two different texts apart, and so it ties exactly the texts that fold to one, which come out in id order. Without a collation it orders by the folds' bytes. A query's `collate` orders it in another collation, still by its folds. `~`, which ignores case on any text by lowercasing both sides, asks of a `nocase` field whether its fold holds the needle's: `straße ~ "SS"`. Its indexes hold the folds: a `@hash` or `@unique` bucket every spelling of one text, a `@sorted` index and an index over several fields their order, so an equality, an `in`, a range and an `order … limit` walk are answered as the scan answers them, row for row — a test holds every filter, order, group, facet and unique index over mixed-case data in ASCII, Turkish, German, Greek, Cherokee and full width to a twin collection holding Python's folds in plain fields. A `lookup` finds a child by its key as the child's field compares: a `nocase` one by the parent's value's fold. The schema writes the word into the file (a binary from before refuses it), `describe`, `GET /_schema?as=fenecql` and the declared schema carry it, a replica — a server's or a synced page's — compares as the primary does, and `fenec import` takes PostgreSQL's `citext` and SQLite's `COLLATE NOCASE` as it. What folding costs, over 100 000 rows (`make nocase-bench`: a plain field holding one spelling and asked it, a `nocase` one holding a mix and asked another; names a third past ASCII): | 100 000 rows | plain | `nocase` | | a put, asking `@unique` | 1 209 ns | 1 200 ns | | an email by `@unique` | 1.08 µs | 1.16 µs | | a city's rows counted by `@hash` | 3.15 µs | 3.27 µs | | a name by the scan | 5.47 ms | 6.79 ms | | a name's part by `~` | 12.7 ms | 10.4 ms | | an order by name, a page, by the scan | 8.48 ms | 16.2 ms | | rows by city, `group` | 7.22 ms | 7.23 ms | | a facet of names by the scan | 6.89 ms | 8.32 ms | | `in` a list of 10 names, by the scan | 6.90 ms | 11.9 ms | An index lookup folds the one value it is asked. A scan folds a row only as far as it differs from the value — mostly its first character — with nothing allocated; `~` folds it whole, and still comes out quicker than lowercasing both sides. An order folds each row's key once, then sorts the folds; a `@sorted` index holds them folded and is walked instead. The fold is the standard library's lowercase mapping, and for the 297 code points whose fold is not their lowercase — 41 runs — its lowercase, uppercase and lowercase again: no table of its own beyond those runs. The browser module grew 11.5 KB, 5.0 KB gzip, 3.7 KB brotli — the engine's indexes, filters, groups, facets and orders 6.4 KB of it, the fold and its comparisons 2.1 — and a collection without a `nocase` field reads and writes as it did. ## Changing a collection A collection takes a field, lets one go, or renames one, and none of it rewrites a document: it is as quick over a million documents as over none. ```fenecql alter collection orders add field note text alter collection orders add field code text @unique alter collection orders drop field note alter collection orders rename field total to amount ``` - **A field added reads `null`** in every document written before it, and cannot be `required`. - **A field dropped is gone** from every read and write, and its index with it. `compact` gives its space back. - **A type does not change in place.** Add a field of the new type, `set` it from the old one, and drop the old one. What a field change does to stored documents A document is its values one after another in field order, so what a change costs is what it does to that order. A field added goes last: a document written before it ends before the field's place, and reads `null` there. A rename is the schema alone. A field dropped leaves its place in the documents written before the drop — skipped on read, written as `null` after it, refused by name everywhere — and `compact` writes every document without it, copying each other value as its bytes stand. A field added under a dropped one's name is a new field: the old values stay in the dropped place, and the new field reads `null` until it is written. A change of type is the one thing that would rewrite every document under the write lock, and is not done. Each is a write as a `create index` is: one of a block, put back by its `ROLLBACK` with the writes after it, seen by a subscriber as a schema change, applied by a replica, carried by the change stream (`op: "alter"`, the documents after it read by the schema it made). In the file it is record kind 12, and a dropped place a nameless field of type tag 12, both of which a binary from before them refuses rather than read the fields after a dropped one a place early. The index of a field added is built over the documents there, every one of them `null`; a dropped field's goes, and a renamed one's goes with the name, a vector field's graph included. Measured over a million rows in memory: an add, a rename and a drop take 0.025, 0.021 and 0.004 ms. Reading a field past where a document ends costs nothing a scan can measure: five scans of the million took 21.2 to 39.1 ms before the feature and 21.6 to 39.3 after, from 1.7% slower to 1.7% faster by the scan — as the same build with the reads written as before was, which makes it where the code lands, not what it does — and the browser module's scan of 20 000 rows 1.70 ms against 1.71. A `compact` that takes a dropped field out took 95 ms against 56 for one with none to take: it writes each document again rather than copy it. On a server it holds the write lock, where a compact otherwise runs beside the database. ## Inspecting the database ```fenecql collections describe articles compact articles ``` `compact` drops deleted data and rewrites the file. What compact leaves in place `compact` drops the dead records and rewrites the file. The indexes stand — the documents are the same ones, and every index is keyed by id — except a vector graph holding tombstones, which is rebuilt: nothing else takes a deleted or rewritten document's node out of it. See [Limits](https://fenecdb.com/docs/limits#index-build-open-and-maintenance). --- Source: https://fenecdb.com/docs/fenecql-indexes # Indexes One index makes one kind of question fast: hash, ordered, unique, over several fields, and rows that expire. An index makes one kind of question fast and gives the rows that reading every row would. Declare it on a field when the collection is made, or add it later with `create index`. Run in [the shell](https://fenecdb.com/docs/shell#quickstart), answers and all; the timings are from one run. ```text fenec> create collection events (user int, kind text, at timestamp); collection `events` created fenec> put events [{user: 1, kind: "view", at: "2026-05-03T09:00:10Z"}, {user: 1, kind: "buy", at: "2026-05-03T09:05:30Z"}, {user: 2, kind: "view", at: "2026-05-03T09:01:00Z"}, {user: 3, kind: "view", at: "2026-05-03T10:20:00Z"}, {user: 3, kind: "buy", at: "2026-05-03T10:40:00Z"}]; 5 records (196.17µs) fenec> explain get events where kind = "buy" order at desc limit 2 plan ----------------------------------------------------------- filter: a full scan, 5 of 5 rows tested, 2 matched order: at desc, every key read, 2 matches, all put in order rows: 2 (3 rows, 188.29µs) fenec> create index on events (kind) @hash; index built on `events.kind` fenec> create index on events (at) @sorted; index built on `events.at` fenec> explain get events where kind = "buy" order at desc limit 2 plan ----------------------------------------------------------------------- order: the ordered index on at not walked, an equality names fewer rows filter: the hash index on kind, 2 rows, which is the answer order: at desc, every key read, 2 matches, all put in order rows: 2 (4 rows, 327.04µs) fenec> explain get events where at >= "2026-05-03T10:00:00Z" order at desc limit 2 plan -------------------------------------------------- order: walked the ordered index on at desc, 2 rows rows: 2 (2 rows, 253.75µs) fenec> describe events events user int kind text @hash at timestamp @sorted ``` With no index the filter tested all five rows. With a `@hash` on `kind` it names the two rows holding `"buy"`, and with a `@sorted` on `at` a range and an order walk the index and stop at the page. Text and vector indexes are on [Text search](https://fenecdb.com/docs/fenecql-search) and [Vector search](https://fenecdb.com/docs/fenecql-vector). ## Indexes An index makes one kind of question fast. Declare it on a field when you create the collection, or add it later with `create index`. ```fenecql create collection articles ( title text, body text @text(k1=0.9, b=0.4, prefix=0), year int @hash, slug text @unique, published timestamp @sorted, embed vector<768> @hnsw(cosine, m=16, ef_construction=200) ) ``` - **One index per field:** `@hash` for equality, `@sorted` for ranges and order, `@text` for ranked text search, `@hnsw` for nearest neighbours, `@geo` for points near a place. A path into a `json` field takes `@hash`, `@unique` or `@sorted` of its own ([Objects and paths](https://fenecdb.com/docs/fenecql-schema#json)), and an index over several fields is the collection's, beside them ([below](#composite)). - **`@unique` is a `@hash` that refuses a second document** holding a value one holds already. `null` is no value: any number of documents may hold it. - **Only filters joined by `and` use an index.** Anything under `or`, and `!=`, `~` or `has`, reads every row. - **For Turkish, Chinese or Japanese text**, see `prefix` and `chars` below. Index options and what they cost, measured ```fenecql create index on articles (embed) @hnsw(cosine) create index on articles (body) @text create index on articles (body) @text(prefix=6) -- for an inflected language create index on products (title) @text(chars) -- short Chinese or Japanese text create index on articles (title) @sorted create index on users (email) @unique -- refused if a value is held twice ``` A field takes one index. `@hash` answers equality, `@sorted` ranges and order, `@text` ranked text retrieval, `@hnsw` nearest neighbours. `@sorted` is for `int`, `float`, `timestamp` and `text` — the types with an order. `@unique` is a `@hash` index that refuses a second document holding a value one holds already: a `put`, an `insert` or a `set` that would write one is refused, and nothing of the statement is written — `409` over HTTP, as a taken id is. It answers equality and a `lookup` as `@hash` does. A block — a `/batch`, a run of several — is checked against its own earlier writes, so it may free a value and take it again, and a document keeping its own value is no second one. `null` is no value, as in SQL: any number of documents hold it. Values are one where `=` finds them one, so `-0.0` and `0.0` collide. `create index ... @unique` over documents that hold a value twice is refused and names the value and two of its documents. A replica takes what its primary took and does not ask again; `describe` shows the index as `@unique`. The refusal names the document holding the value and the value, but to a [scoped token](https://fenecdb.com/docs/http-access#scoped-access) it names the field alone (``users.email` is unique, and the value is taken`): the row is another user's, which the token may not read. The check asks the bucket the value would go in, so the index has to hold every document: the first write after an open builds it, where a plain `@hash` waits for the first read that wants it. An open costs what it did — 19 ms for an image of a million rows either way — and that first write takes 15 ms over 100 000 rows and 259 ms over a million; after it a `put` takes 940 ns against 850 for the same field under `@hash`, the price of the lookup. A collection with no unique field writes as fast as before (848 and 852 ns). An `@hnsw` graph holds a vector once. Documents written with the same vector — the embedding of an empty chunk, a line of boilerplate, a zero vector — share its node, and `near` hands back each of them at the one distance. A node each, a vector written hundreds of times filled its neighbours' lists with copies of itself: over 20 000 rows of 128 dimensions, a third of them copies of 20 vectors, 1 812 copies were found by no search and other queries' recall fell from 0.82 to 0.67; one node each, none is missed and it is 0.87. **`prefix=N` indexes each word's prefixes alongside it** (3..N by default; `prefix_min` moves the lower bound). It is a stemmer that needs no dictionary and no language setting, and it is off unless asked for because it is a trade the corpus decides. Word-boundary matching suits English; it does not suit a language that inflects by gluing suffixes on, where `kitap`, `kitabı` and `kitapların` are three terms sharing a stem the index never sees. Measured on Turkish WebFAQ (144 846 documents, 10 000 queries): nDCG@10 0.4841 → 0.5547, +14.6%, closing about two fifths of the gap to a 278M-parameter multilingual transformer (0.650). The cost is the 3.4× postings it indexes — 58 MB → 182 MB, and 82 → 485 µs per query. On English SciFact the same option is worth +0.0137. **A script written without spaces is indexed in runs of characters.** Chinese, Japanese and Thai run their words together, and Korean hangs its particles on them, so a word is not a term there: a run of Han, kana or Hangul is indexed as its overlapping pairs of characters — `東京都に` is `東京`, `京都`, `都に` — and one of Thai, Lao, Khmer or Myanmar, whose characters are letters, as its triples. A query is split the same way, so `match body "東京"` finds the run it is written in. There is no dictionary and no language setting; a script that spaces its words keeps its words. `chars` indexes each character of Han, kana and Hangul as well, so a query of one character finds every run holding it, which pairs alone do only for a run of one. It pays on short text and costs on long: product titles gain a fifth, news and questions lose a little, and the index holds 1.6 to 1.9× the postings for a query 3 to 8× slower. Off unless asked for. | nDCG@10, BM25 | documents | words whole | runs of characters | with `chars` | | C-MTEB EcomRetrieval, Chinese product titles | 100 902 | 0.006 | 0.439 | **0.528** | | C-MTEB CovidRetrieval, Chinese news | 100 001 | 0.162 | 0.868 | **0.872** | | WebFAQ, Chinese | 132 491 | 0.146 | **0.686** | 0.666 | | JaGovFaqs-22k, Japanese | 22 135 | 0.119 | **0.582** | 0.576 | | WebFAQ, Korean | 102 000 | 0.537 | **0.682** | 0.651 | | WebFAQ, Thai (triples; pairs 0.495) | 47 429 | 0.448 | **0.547** | — | The same pipeline as BEIR (`make beir` over each set in its layout); English SciFact scores what it did, to the fourth decimal. A query over those sets takes 0.09 to 0.66 ms at the median. **A filter reaches an index only inside an `and` chain.** Equality — `=` and `in [..]` — reaches a `@hash` field; `in` is a set of equalities written short, so it is answered as the union of one bucket per element. Either reaches `id`, which the store's own id index answers without a `@hash` being declared or possible. Comparisons — `<`, `<=`, `>`, `>=` and `=` — reach a `@sorted` field, which narrows them to a range of keys. Everything else — `!=`, `~`, `has`, comparisons on a field without `@sorted`, and anything under an `or` — is a full scan: each matching row is decoded and evaluated. `~` in particular is substring matching without ranking and reaches no index; ranked retrieval is `match` over a `@text` field. A full scan reads rows in id order, and starts at the lowest id an `id > x` or `id >= x` in the `and` chain lets through, so a page read after the last id of the one before — `where id > $1 limit 1000` — reads only its own rows: a million rows read that way a page at a time took 8.6 s and take 110 ms. The browser module leaves this out. ## Ordered indexes `@sorted` makes ranges, counts over a range and `order … limit` fast. It always gives the same rows as reading every row would. ```fenecql get articles order published desc limit 20 get articles where published >= "2026-09-01" order published desc limit 20 ``` ```js await db.from('articles').order('published', 'desc').limit(20).rows(); await db.from('articles') .where('published', '>=', '2026-09-01') .order('published', 'desc') .limit(20) .rows(); ``` ```python db.collection("articles").order("published", "desc").limit(20).rows() (db.collection("articles") .where("published", ">=", "2026-09-01") .order("published", "desc") .limit(20) .rows()) ``` ```go db.From("articles").Order("published", "desc").Limit(20).Rows(ctx) db.From("articles"). Where("published", ">=", "2026-09-01"). Order("published", "desc"). Limit(20). Rows(ctx) ``` ```csharp await db.From("articles") .Order("published", "desc") .Limit(20) .RowsAsync(); await db.From("articles") .Where("published", ">=", "2026-09-01") .Order("published", "desc") .Limit(20) .RowsAsync(); ``` ```swift try await db.from("articles") .order("published", "desc") .limit(20) .rows() try await db.from("articles") .where("published", ">=", "2026-09-01") .order("published", "desc") .limit(20) .rows() ``` ```kotlin db.from("articles").order("published", "desc").limit(20).rows() db.from("articles") .where("published", ">=", "2026-09-01") .order("published", "desc") .limit(20) .rows() ``` ```dart await db.from('articles').order('published', 'desc').limit(20).rows(); await db.from('articles') .where('published', '>=', '2026-09-01') .order('published', 'desc') .limit(20) .rows(); ``` ```fenecql get articles order published desc limit $1 -- [20] get articles where published >= $1 order published desc limit $2 -- ["2026-09-01", 20] ``` - **It costs memory and write time**: about 20 bytes a row for a number, plus the text for `text`. - **It is rebuilt after an open** by the first query that needs it; it is never written to the file. When an ordered walk wins over the scan ```fenecql get articles where title >= "k" and title < "l" count get articles where published >= "2026-01-01" near embed $1 limit 10 ``` ```js await db.from('articles') .where('title', '>=', 'k') .where('title', '<', 'l') .count(); await db.from('articles') .where('published', '>=', '2026-01-01') .near('embed', [0.9, 0.1, 0]) .limit(10) .rows(); ``` ```python (db.collection("articles") .where("title", ">=", "k") .where("title", "<", "l") .count()) (db.collection("articles") .where("published", ">=", "2026-01-01") .near("embed", [0.9, 0.1, 0]) .limit(10) .rows()) ``` ```go db.From("articles"). Where("title", ">=", "k"). Where("title", "<", "l"). Count(ctx) db.From("articles"). Where("published", ">=", "2026-01-01"). Near("embed", []float64{0.9, 0.1, 0}). Limit(10). Rows(ctx) ``` ```csharp await db.From("articles") .Where("title", ">=", "k") .Where("title", "<", "l") .CountAsync(); await db.From("articles") .Where("published", ">=", "2026-01-01") .Near("embed", new[] { 0.9, 0.1, 0 }) .Limit(10) .RowsAsync(); ``` ```swift try await db.from("articles") .where("title", ">=", "k") .where("title", "<", "l") .count() try await db.from("articles") .where("published", ">=", "2026-01-01") .near("embed", [0.9, 0.1, 0]) .limit(10) .rows() ``` ```kotlin db.from("articles") .where("title", ">=", "k") .where("title", "<", "l") .count() db.from("articles") .where("published", ">=", "2026-01-01") .near("embed", listOf(0.9, 0.1, 0.0)) .limit(10) .rows() ``` ```dart await db.from('articles') .where('title', '>=', 'k') .where('title', '<', 'l') .count(); await db.from('articles') .where('published', '>=', '2026-01-01') .near('embed', [0.9, 0.1, 0]) .limit(10) .rows(); ``` ```fenecql get articles where title >= $1 and title < $2 count -- ["k", "l"] get articles where published >= $1 near embed $2 limit $3 -- ["2026-01-01", [0.9, 0.1, 0], 10] ``` `order` over a `@sorted` field with a `limit` walks the index in order and stops at the page, testing the rest of the filter on each row it passes; without one, every match's key is read and only `offset + limit` of them are put in order. A filter that matches too little to fill the page within an eighth of the collection sends the walk back to the scan: over a million rows one matching nothing cost 152 ms against the scan's 121, while one matching 1% fills the page in 0.29 ms against 125. A range on a `@sorted` field is read from the index as a set of ids — for a filter, a `count`, or the allowed set of a filtered `near`. | 1 000 000 rows unless noted | scan | `@sorted` | | `order created desc limit 20` | 26.8 ms | 0.001 ms | | the newest 1%, `order created desc limit 20` | 63.1 ms | 0.001 ms | | `where price >= 99900 limit 20`, 0.1% of the rows | 1.44 ms | 0.008 ms | | the same range, `count` | 65.8 ms | 0.008 ms | | `where pct < 1 near embed $1 limit 10`, 1% of 200 000 × 128 | 17.9 ms | 0.16 ms | A comparison with `now()` — `run_at <= now()`, `at >= now() - 3600000` — is a range of the index as one with a literal is: the time is worked out once, before the query is planned, and is the statement's one time. Left a call, a queue's claim walked past its ready jobs into the leased and delayed ones after them, testing each. The planner prefers a smaller set when it has one: an equality over a `@hash` field, or a narrow range on another `@sorted` field, names fewer rows than the walk would pass, so those are sorted instead. A range wider than half the collection is left to the scan, which is cheaper than gathering and sorting that many ids. **A comparison reaches the index only when its literal says exactly what the scan would compare.** `price < 12.5` on an `int` field, or an integer past 253 against a `timestamp`, is evaluated row by row — the index narrows with the comparisons it can express and the rest are tested — because an answer that differs from the scan's is a wrong answer, not a fast one. Ties come out in ascending `id` either way, as the scan leaves them. The cost is paid on write and in memory: about 20 bytes a row for a number, plus the text for `text`, and an insert into a sorted run. Over a million rows two ordered indexes took a bulk load from 0.52 s to 0.88 s, and the index is built from the documents by the first query that reads it after an open, like the hash and text indexes — it is never written to the file. ## Indexes over several fields `@sorted(owner, created desc)` keeps the rows in the order of several fields, the first first. The page an app reads most — one user's latest twenty, a tenant's inbox, a feed — is a run of it, read to the page and no further. `@unique(org, slug)` refuses a second row holding the same values in every part. ```fenecql create collection posts ( owner int, created timestamp, title text, @sorted(owner, created desc) ) create index on pages (org, slug) @unique -- or @unique(org, slug) in the collection ``` ```fenecql get posts where owner = $1 order created desc limit 20 -- a run of the index, 20 rows read get posts where owner in [$1, $2, $3] order created desc limit 20 -- three runs, merged get posts where owner = $1 and created >= $2 count -- counted off the index ``` ```js await db.from('posts') .where('owner', 7) .order('created', 'desc') .limit(20) .rows(); await db.from('posts') .where('owner', 'in', [7, 8, 9]) .order('created', 'desc') .limit(20) .rows(); await db.from('posts') .where('owner', 7) .where('created', '>=', '2026-10-01') .count(); ``` ```python (db.collection("posts") .where("owner", 7) .order("created", "desc") .limit(20) .rows()) (db.collection("posts") .where("owner", "in", [7, 8, 9]) .order("created", "desc") .limit(20) .rows()) (db.collection("posts") .where("owner", 7) .where("created", ">=", "2026-10-01") .count()) ``` ```go db.From("posts"). Where("owner", "=", 7). Order("created", "desc"). Limit(20). Rows(ctx) db.From("posts"). Where("owner", "in", []any{7, 8, 9}). Order("created", "desc"). Limit(20). Rows(ctx) db.From("posts"). Where("owner", "=", 7). Where("created", ">=", "2026-10-01"). Count(ctx) ``` ```csharp await db.From("posts") .Where("owner", 7) .Order("created", "desc") .Limit(20) .RowsAsync(); await db.From("posts") .Where("owner", "in", new object?[] { 7, 8, 9 }) .Order("created", "desc") .Limit(20) .RowsAsync(); await db.From("posts") .Where("owner", 7) .Where("created", ">=", "2026-10-01") .CountAsync(); ``` ```swift try await db.from("posts") .where("owner", 7) .order("created", "desc") .limit(20) .rows() try await db.from("posts") .where("owner", "in", [7, 8, 9]) .order("created", "desc") .limit(20) .rows() try await db.from("posts") .where("owner", 7) .where("created", ">=", "2026-10-01") .count() ``` ```kotlin db.from("posts") .where("owner", 7) .order("created", "desc") .limit(20) .rows() db.from("posts") .where("owner", "in", listOf(7, 8, 9)) .order("created", "desc") .limit(20) .rows() db.from("posts") .where("owner", 7) .where("created", ">=", "2026-10-01") .count() ``` ```dart await db.from('posts') .where('owner', 7) .order('created', 'desc') .limit(20) .rows(); await db.from('posts') .where('owner', 'in', [7, 8, 9]) .order('created', 'desc') .limit(20) .rows(); await db.from('posts') .where('owner', 7) .where('created', '>=', '2026-10-01') .count(); ``` ```fenecql get posts where owner = $1 order created desc limit $2 -- [7, 20] get posts where owner in [$1, $2, $3] order created desc limit $4 -- [7, 8, 9, 20] get posts where owner = $1 and created >= $2 count -- [7, "2026-10-01"] ``` - **Equalities on its first fields, then a range or an order on the next.** `where owner = $1 order created desc` reads its run; `where created > $1` alone does not use `(owner, created)`, since `owner` comes first. - **An order matches it** in its fields' directions, or every one the other way round: `(owner, created desc)` answers `order created desc` and `order created` for one owner. - **It gives the scan's answer, row for row**, ties in ascending `id` either way, as `@sorted` does. - **A `lookup` keyed by its first fields** reads each parent's children as a run of it, walked in the level's order where that is the index's next parts: [`lookup lines on (org, order) = (org, id) order line asc limit 5`](https://fenecdb.com/docs/fenecql-lookup#a-key-over-several-fields). - **Up to 8 fields** of `bool`, `int`, `float`, `timestamp`, `text` (in its collation) or `json`, a path into one among them (`meta.lang`). A field keeps its own index beside it. How a key over several fields is laid out ```fenecql create index on posts (owner, created desc) @sorted -- built beside the database on a server drop index on posts (owner, created) -- by its fields explain get posts where owner = 7 order created desc limit 20 -- order: walked the index on (owner, created desc), owner fixed, 20 of 100 rows read, 20 kept ``` A key is its fields' values laid end to end, each written so that the bytes order as the values do — an int's sign turned over, a float's bits made to order, a text escaped and ended, a descending field's bytes inverted — so an equality on its first fields is a run of the keys and a range on the next one is a run inside it. A text in a collation is kept as written and compared in it. `null` stands below every value of a field, as `order` puts it; a row holding a float's `NaN`, which compares equal to everything, or a json value no order has a place for (a boolean, a list, an object, an int past 253) is held apart: while one is, the index is walked for no `order`, and a filter's run takes those rows with it, each tested. The planner takes the `and` chain's terms in the index's order — an equality, or an `in` whose every element it can say exactly, on each field as far as one is fixed, then the comparisons on the next — and tests every other term row by row; a term it cannot say exactly (a `null`, `12.5` against an `int`) is left to that test, so the answer never changes. An `in` makes a run for each element, 256 at most together; under an `order` on the fields after it the runs are walked together, merged by those fields. Of the plans, the narrower set wins by exact counts: the run is two searches, a `@hash` bucket its length, so a filter the index says whole reads the page and nothing more, and one it does not is walked only while its run is no longer than any set another index names — and gives up past an eighth of the collection, as a `@sorted` walk does. A `count` the index says whole is counted off it without a row read. `explain` names the index, the fields fixed, the runs and the range. `@unique` over several fields is asked before every `put`, `insert` and `set`: a second row holding the same values in every part is refused, `409` over HTTP, naming the fields and the other row — to a scoped token the fields alone. A `null` in any part is no value. `put ... if absent` passes over the row holding them, and `if absent else set` sets it. `create index ... @unique` over rows already holding a key twice is refused naming two of them. It is derived data, built from the documents by the first read after an open, kept on every write, put back with a block that does not land, and never written to the file. A field renamed keeps it under the new name; a field dropped takes it off. A sync replica builds what the schema declares, a unique one as an ordered one, as its `@unique` fields are `@hash`. | 1 000 000 posts over 10 000 owners | p50 | p99 | built in | memory | | `where owner = $1 order created desc limit 20`, `@sorted(owner, created desc)` | 0.006 ms | 0.011 ms | 147 ms | 40 MB | | the same over `owner @hash`, its bucket sorted | 0.012 ms | 0.017 ms | 75 ms | 11 MB | | the same over `created @sorted`, walked and given up | 24.4 ms | 40.5 ms | 34 ms | 20 MB | | the same by the scan | 21.3 ms | 37.4 ms | — | — | | a feed, `owner in` ten owners, `@sorted(owner, created desc)` | 0.022 ms | 0.029 ms | | | | the feed over `owner @hash` | 0.100 ms | 0.136 ms | | | Writes pay for it: a lone `put` into those million rows took 715 ns with no index, 851 beside `created @sorted` and `owner @hash`, and 1 561 beside `@sorted(owner, created desc)`, whose first field puts each write anywhere in it. A unique one asks its run before each write: 1 745 ns a put against 1 285 for `@unique` on the two joined into one field, and a read by both fields 2.5 µs against 1.3 by the joined field — a hash index over several fields would answer that read sooner, by a microsecond, and was not worth a third index's code beside this one (`make composite-bench`). ## Rows that expire `@ttl` on a `timestamp` field makes a row expire that long after its value — for sessions, caches and anything else kept for a while. ```fenecql create collection sessions (user text @hash, seen timestamp @ttl(30m)) ``` - **Reads are exact at once.** A row past its time is never returned, even before it is deleted: every read of the collection leaves it out. - **The server deletes it.** A primary sweeps once a minute, as ordinary deletes that replicas, `/_changes`, archives and subscriptions see as any. - **`expired()` reads what the others leave out,** for a reaper that gives back what a row held before the sweep deletes it ([below](#reaper)). - **Durations** are whole `ms`, `s`, `m`, `h` or `d`: `@ttl(45s)`, `@ttl(12h)`, `@ttl(7d)`. The reaper, and what @ttl costs, measured ```fenecql put sessions {user: "ada", seen: now()} set sessions {seen: now()} where user = "ada" -- seen again: lives on get sessions where user = "ada" -- nothing once 30 minutes pass ``` ```js await db.from('sessions').insert({ user: 'ada', seen: expr('now()') }); await db.from('sessions') .where('user', 'ada') .update({ seen: expr('now()') }); await db.from('sessions').where('user', 'ada').rows(); ``` ```python db.collection("sessions").insert({"user": "ada", "seen": expr("now()")}) (db.collection("sessions") .where("user", "ada") .update({"seen": expr("now()")})) db.collection("sessions").where("user", "ada").rows() ``` ```go db.From("sessions"). Insert(ctx, fenecdb.D("user", "ada", "seen", fenecdb.Expr("now()"))) db.From("sessions"). Where("user", "=", "ada"). Update(ctx, fenecdb.D("seen", fenecdb.Expr("now()"))) db.From("sessions").Where("user", "=", "ada").Rows(ctx) ``` ```csharp await db.From("sessions") .InsertAsync(new { user = "ada", seen = Computed.Expr("now()") }); await db.From("sessions") .Where("user", "ada") .UpdateAsync(new { seen = Computed.Expr("now()") }); await db.From("sessions").Where("user", "ada").RowsAsync(); ``` ```swift try await db.from("sessions") .insert(["user": "ada", "seen": .expr("now()")] as Value) try await db.from("sessions") .where("user", "ada") .update(["seen": .expr("now()")] as Value) try await db.from("sessions").where("user", "ada").rows() ``` ```kotlin db.from("sessions") .insert(mapOf("user" to "ada", "seen" to Computed.expr("now()"))) db.from("sessions") .where("user", "ada") .update(mapOf("seen" to Computed.expr("now()"))) db.from("sessions").where("user", "ada").rows() ``` ```dart await db.from('sessions') .insert({'user': 'ada', 'seen': Computed.expr('now()')}); await db.from('sessions') .where('user', 'ada') .update({'seen': Computed.expr('now()')}); await db.from('sessions').where('user', 'ada').rows(); ``` ```fenecql put sessions {user: $1, seen: now()} -- ["ada"] set sessions {seen: now()} where user = $1 -- ["ada"] get sessions where user = $1 -- ["ada"] ``` ```fenecql create index on events (at) @ttl(7d) -- an existing timestamp field alter collection sessions alter field seen @ttl(1h) -- another expiry alter collection sessions alter field seen @sorted -- none: the rows live ``` A read of the collection — `get`, `count`, an aggregate, `near`, `match`, a `lookup` level, an inner `get`, a `set` or `del` by filter, and a subscription's shape — has `not (seen <= now - ttl)` added to its filter, the way a token's rule is added: a row is gone at the moment its time plus the ttl is reached. A row whose field is `null` has no time to expire from, and lives. In the browser the module has no clock: `now` is the page's, `Date.now()` unless `db.now` is set, as every time is handed to it; the page deletes nothing, and a row past its time stays in its file, out of every read. A live query in the page is woken by writes, not by time passing. `@ttl` is an ordered index whose rows expire, so the field also does everything `@sorted` does, and a collection's rows expire by one such field. The sweeper deletes a range of it at a time rather than scan for the rows past their time: found under the read lock, deleted under the write lock 1 000 at a time, each batch a block of ordinary deletes, its fsync run once the lock is let go. A replica never sweeps — its writes are its primary's, the sweep's among them — and neither does a tenant that follows another node. The server's `Idempotency-Key`s are kept this way. The ttl is a kind of index in the file (kind 9, the milliseconds behind it), so a binary from before it refuses the file rather than hand out the rows past their time. A write takes the place of a row past its time: an `insert` naming its id writes over it, and a `@unique` value it holds is let go of with it, the row deleted in the same statement — so a lock taken by `insert` and left to expire can be taken again at once ([Instead of Redis](https://fenecdb.com/docs/redis#lock)). BM25's statistics count the row until the sweep. ### Reading what expired: the reaper A row past its time is out of every read, so a hold that lapsed cannot be found to give its money back, nor a reservation its stock. `expired()` in a filter reads those rows — and only those, where the filter says so — wherever a filter goes: `get`, `count`, a `lookup` level, an inner `get`, `set` and `del`. It is `until <= now - ttl`, a range of the field's ordered index; `not expired()` is what a read finds without it. ```fenecql create collection holds (account text @hash, amount int, until timestamp @ttl(1ms)) get holds select id, account, amount where expired() limit 100 -- what lapsed -- each given back once, in one block (a /batch): the delete is the guard del holds where expired() and id = $1 require 1 set accounts {held: held - $2} where ext = $3 and held >= $2 require 1 ``` A capture needs no guard of its own against the lapse: `get holds where id = $1 limit 1 require 1` finds no hold past its time. Two reapers, or a reaper and a capture, racing for one hold: the `del ... require 1` lets one through, and the other's block is put back whole. The sweeper leaves a row a minute past its time before it deletes it, so a reaper that comes by once a minute sees every one; a row it misses for longer is deleted unseen, and what it held is the reconciliation's to find. A scoped token reads them only by the policy's `expired` grant ([Scoped access](https://fenecdb.com/docs/http-access#scoped-access)). Without `@ttl` on the collection, `expired()` is an error. **What it costs, measured** (`make ttl-bench`, a million rows, half past their time). A read tests each row's time as part of its filter, as the same test written by hand does: `count` becomes a scan, 68.8 ms against 1.1 with no expiry, and a filter that scanned already reads one field more, `where s = "w7" count` 68.5 ms against 51.9 — 70.1 with the test written out. A collection with no `@ttl` reads as it did. The sweep of 100 000 rows past their time out of a file of 200 000 took 0.56 s in batches of 1 000, each holding the write lock 1.06 ms at the median and 2.3 at most. --- Source: https://fenecdb.com/docs/fenecql-lookup # Lookup and subqueries Another collection's documents, attached to a row or used as a filter. `lookup` attaches the matching documents of another collection to each row, and `in (get …)` keeps the rows whose value another collection's rows hold. A SQL `JOIN` on a key is one or the other ([From SQL](https://fenecdb.com/docs/sql#joins)). Run in [the shell](https://fenecdb.com/docs/shell#quickstart), answers and all; the timings are from one run. ```text fenec> create collection customers (name text, country text); collection `customers` created fenec> create collection orders (customer int @hash, total int, status text); collection `orders` created fenec> put customers [{name: "Ada", country: "UK"}, {name: "Bo", country: "TR"}, {name: "Cem", country: "TR"}]; 3 records (112.38µs) fenec> put orders [{customer: 1, total: 1200, status: "paid"}, {customer: 1, total: 800, status: "open"}, {customer: 1, total: 300, status: "paid"}, {customer: 2, total: 450, status: "paid"}]; 4 records (116.75µs) fenec> get customers select name lookup orders on customer select total, status order total desc limit 2 name orders.total orders.status ---- ------------ ------------- Ada 1200 paid Ada 800 open Bo 450 paid Cem - - (4 rows, 198.08µs) fenec> get customers select name lookup orders on customer required select total where status = "open" name orders.total ---- ------------ Ada 800 (1 rows, 184.96µs) fenec> get orders select customer, total where customer in (get customers select id where country = "TR") customer total -------- ----- 2 450 (1 rows, 176.00µs) ``` The `limit` after `lookup` counts children per parent: Ada keeps her two largest orders, Bo his one, and Cem, who has none, keeps his row. The shell prints the children as joined rows; over JSON they nest under `orders`. `required` keeps only the parents with a match, and the last line is a filter by another collection's rows. ## The lookup clause `lookup` attaches matching documents from another collection to each row — reviews to their products, say. A `limit` after it counts children per parent. ```fenecql get products limit 20 lookup reviews on product_id order created desc limit 3 ``` ```js await db.from('products') .limit(20) .lookup( 'reviews', { on: 'product_id', order: [['created', 'desc']], limit: 3 } ) .rows(); ``` ```python (db.collection("products") .limit(20) .lookup("reviews", on="product_id", order=[("created", "desc")], limit=3) .rows()) ``` ```go db.From("products"). Limit(20). Lookup( "reviews", fenecdb.On("product_id"), fenecdb.Sort("created", "desc"), fenecdb.Limit(3), ). Rows(ctx) ``` ```csharp await db.From("products") .Limit(20) .Lookup( "reviews", on: "product_id", order: [new SortKey("created", "desc")], limit: 3 ) .RowsAsync(); ``` ```swift try await db.from("products") .limit(20) .lookup( "reviews", on: "product_id", order: [SortKey("created", "desc")], limit: 3 ) .rows() ``` ```kotlin db.from("products") .limit(20) .lookup( "reviews", on = "product_id", order = listOf(SortKey("created", "desc")), limit = 3 ) .rows() ``` ```dart await db.from('products') .limit(20) .lookup( 'reviews', on: 'product_id', order: [SortKey('created', 'desc')], limit: 3 ) .rows(); ``` ```fenecql get products limit $1 lookup reviews on product_id order created desc limit $2 -- [20, 3] ``` - **Clauses after `lookup` apply to the child collection**; clauses before it apply to the parent. - **The child's key needs `id`, a `@hash` index, or an index over several fields it leads.** A key of several fields is written `on (org, order) = (org, id)`. - **`required` keeps only parents that have a match.** - **A level's list may aggregate** — `lookup orders on shop select count(*) as n, sum(total) as revenue` — each parent's children folded, one row a parent; and a `get` whose list aggregates takes a `lookup` off its groups. See A level that folds. Chains, keys over several fields, folded levels, required, flat rows and costs ```fenecql get products lookup reviews on product_id get products limit 20 lookup reviews on product_id order created desc limit 3 get products select name where price >= 10000 lookup reviews on product_id select body where stars >= 4 limit 3 get products lookup tags on sku = sku ``` ```js await db.from('products') .lookup('reviews', { on: 'product_id' }) .rows(); await db.from('products') .limit(20) .lookup( 'reviews', { on: 'product_id', order: [['created', 'desc']], limit: 3 } ) .rows(); await db.from('products') .select('name') .where('price', '>=', 10000) .lookup( 'reviews', { on: 'product_id', select: ['body'], where: { stars: { gte: 4 } }, limit: 3 } ) .rows(); await db.from('products') .lookup('tags', { on: 'sku', parentKey: 'sku' }) .rows(); ``` ```python db.collection("products").lookup("reviews", on="product_id").rows() (db.collection("products") .limit(20) .lookup("reviews", on="product_id", order=[("created", "desc")], limit=3) .rows()) (db.collection("products") .select("name") .where("price", ">=", 10000) .lookup( "reviews", on="product_id", select=["body"], where={"stars": {"gte": 4}}, limit=3 ) .rows()) (db.collection("products") .lookup("tags", on="sku", parent_key="sku") .rows()) ``` ```go db.From("products"). Lookup("reviews", fenecdb.On("product_id")). Rows(ctx) db.From("products"). Limit(20). Lookup( "reviews", fenecdb.On("product_id"), fenecdb.Sort("created", "desc"), fenecdb.Limit(3), ). Rows(ctx) db.From("products"). Select("name"). Where("price", ">=", 10000). Lookup( "reviews", fenecdb.On("product_id"), fenecdb.Select("body"), fenecdb.Where(fenecdb.Cmp("stars", "gte", 4)), fenecdb.Limit(3), ). Rows(ctx) db.From("products"). Lookup("tags", fenecdb.On("sku"), fenecdb.ParentKey("sku")). Rows(ctx) ``` ```csharp await db.From("products") .Lookup("reviews", on: "product_id") .RowsAsync(); await db.From("products") .Limit(20) .Lookup( "reviews", on: "product_id", order: [new SortKey("created", "desc")], limit: 3 ) .RowsAsync(); await db.From("products") .Select("name") .Where("price", ">=", 10000) .Lookup( "reviews", on: "product_id", select: ["body"], where: Cond.Cmp("stars", "gte", 4), limit: 3 ) .RowsAsync(); await db.From("products") .Lookup("tags", on: "sku", parentKey: "sku") .RowsAsync(); ``` ```swift try await db.from("products").lookup("reviews", on: "product_id").rows() try await db.from("products") .limit(20) .lookup( "reviews", on: "product_id", order: [SortKey("created", "desc")], limit: 3 ) .rows() try await db.from("products") .select("name") .where("price", ">=", 10000) .lookup( "reviews", on: "product_id", select: ["body"], where: .cmp("stars", "gte", 4), limit: 3 ) .rows() try await db.from("products") .lookup("tags", on: "sku", parentKey: "sku") .rows() ``` ```kotlin db.from("products").lookup("reviews", on = "product_id").rows() db.from("products") .limit(20) .lookup( "reviews", on = "product_id", order = listOf(SortKey("created", "desc")), limit = 3 ) .rows() db.from("products") .select("name") .where("price", ">=", 10000) .lookup( "reviews", on = "product_id", select = listOf("body"), where = Cond.cmp("stars", "gte", 4), limit = 3 ) .rows() db.from("products").lookup("tags", on = "sku", parentKey = "sku").rows() ``` ```dart await db.from('products').lookup('reviews', on: 'product_id').rows(); await db.from('products') .limit(20) .lookup( 'reviews', on: 'product_id', order: [SortKey('created', 'desc')], limit: 3 ) .rows(); await db.from('products') .select(['name']) .where('price', '>=', 10000) .lookup( 'reviews', on: 'product_id', select: ['body'], where: Cond.cmp('stars', 'gte', 4), limit: 3 ) .rows(); await db.from('products') .lookup('tags', on: 'sku', parentKey: 'sku') .rows(); ``` ```fenecql get products lookup reviews on product_id get products limit $1 lookup reviews on product_id order created desc limit $2 -- [20, 3] get products select name where price >= $1 lookup reviews on product_id select body where stars >= $2 limit $3 -- [10000, 4, 3] get products lookup tags on sku = sku ``` `lookup` attaches the matching documents of another collection to the row they belong to. `on` names the child's field; the parent's key is `id` unless `= ` says otherwise, which is the foreign-key-to-primary-key case spelled out. It is **terminal**: everything before it binds to the driving collection, everything after it to the looked-up one. That is the whole scoping rule, and it is why there are no qualified names in the language — `where` on either side means what it has always meant. The price is that a parent clause cannot follow a child one, which reads the way the query runs anyway. ```fenecql get shops limit 20 lookup orders on shop_id limit 3 lookup lines on order_id limit 5 ``` ```js await db.from('shops') .limit(20) .lookup('orders', { on: 'shop_id', limit: 3 }) .lookup('lines', { on: 'order_id', limit: 5 }) .rows(); ``` ```python (db.collection("shops") .limit(20) .lookup("orders", on="shop_id", limit=3) .lookup("lines", on="order_id", limit=5) .rows()) ``` ```go db.From("shops"). Limit(20). Lookup("orders", fenecdb.On("shop_id"), fenecdb.Limit(3)). Lookup("lines", fenecdb.On("order_id"), fenecdb.Limit(5)). Rows(ctx) ``` ```csharp await db.From("shops") .Limit(20) .Lookup("orders", on: "shop_id", limit: 3) .Lookup("lines", on: "order_id", limit: 5) .RowsAsync(); ``` ```swift try await db.from("shops") .limit(20) .lookup("orders", on: "shop_id", limit: 3) .lookup("lines", on: "order_id", limit: 5) .rows() ``` ```kotlin db.from("shops") .limit(20) .lookup("orders", on = "shop_id", limit = 3) .lookup("lines", on = "order_id", limit = 5) .rows() ``` ```dart await db.from('shops') .limit(20) .lookup('orders', on: 'shop_id', limit: 3) .lookup('lines', on: 'order_id', limit: 5) .rows(); ``` ```fenecql get shops limit $1 lookup orders on shop_id limit $2 lookup lines on order_id limit $3 -- [20, 3, 5] ``` A second `lookup` **chains** onto the first: its clauses bind to its collection, and `on = ` names a field of the level immediately above. Position keeps scoping the query one level deeper, so exactly one collection is in scope at any point and it is the last one named — which is what keeps qualified names out of the language at any depth. Up to **8** levels; deeper is an error, not a silent truncation. **`limit` after `lookup` counts children per parent.** This is the clause's reason to exist. A join's limit counts pairs, so a single product with 56 374 reviews consumes the whole page and the other nineteen never appear; asking a join for three reviews each needs a window function. Here twenty products with three reviews apiece is one query with a bounded answer. The child key must be `id` or carry `@hash`. Refusing an unindexed one is the same rule `near` and `match` follow: the clause is one bucket probe per parent, and quietly becoming a full scan of the child collection instead — 12.98 ms against 0.092 ms on the same 200 000 rows — is not a thing a query should do without saying so. ### A key over several fields ```fenecql create collection lines (org int, order int, line int, qty int, @sorted(org, order, line)) ``` ```fenecql get orders where org = $1 limit 20 lookup lines on (org, order) = (org, id) order line asc limit 5 ``` ```js await db.from('orders') .where('org', 7) .limit(20) .lookup( 'lines', { on: ['org', 'order'], parentKey: ['org', 'id'], order: 'line', limit: 5 } ) .rows(); ``` ```python (db.collection("orders") .where("org", 7) .limit(20) .lookup( "lines", on=["org", "order"], parent_key=["org", "id"], order="line", limit=5 ) .rows()) ``` ```go db.From("orders"). Where("org", "=", 7). Limit(20). Lookup( "lines", fenecdb.On("org", "order"), fenecdb.ParentKey("org", "id"), fenecdb.Sort("line", "asc"), fenecdb.Limit(5), ). Rows(ctx) ``` ```csharp await db.From("orders") .Where("org", 7) .Limit(20) .Lookup( "lines", on: new[] { "org", "order" }, parentKey: new[] { "org", "id" }, order: [new SortKey("line")], limit: 5 ) .RowsAsync(); ``` ```swift try await db.from("orders") .where("org", 7) .limit(20) .lookup( "lines", on: ["org", "order"], parentKey: ["org", "id"], order: ["line"], limit: 5 ) .rows() ``` ```kotlin db.from("orders") .where("org", 7) .limit(20) .lookup( "lines", on = listOf("org", "order"), parentKey = listOf("org", "id"), order = listOf(SortKey("line")), limit = 5 ) .rows() ``` ```dart await db.from('orders') .where('org', 7) .limit(20) .lookup( 'lines', on: ['org', 'order'], parentKey: ['org', 'id'], order: [SortKey('line')], limit: 5 ) .rows(); ``` ```fenecql get orders where org = $1 limit $2 lookup lines on (org, order) = (org, id) order line asc limit $3 -- [7, 20, 5] ``` Where one field does not say which children are a row's — a tenant's rows by `(org, user)`, an order's lines in a file every tenant's orders share, a child keyed by two fields of its parent — `on` pairs a field of each side for each part, in order: `(org, order) = (org, id)` is `lines.org = orders.org and lines.order = orders.id`. The positional rule is the one above: the names on the left are the looked-up collection's, those on the right the level immediately above's, `id` among them. A null on either side matches nothing, as for a single key. The child side is answered by an [index over several fields](https://fenecdb.com/docs/fenecql-indexes#composite) whose first parts are the child's fields, in any order: `@sorted(org, order, line)` or `@unique(order, org)` answer `on (org, order) = …`. A parent's values, each made its child field's type as a single key's is, laid end to end as the index lays a row's, are the start of its children's keys, so its children are one run of the index, found by two searches. No such index is refused, naming the one to declare — `needs an index whose first fields are those (declare it with @sorted(org, order))` — for the reason an unindexed single key is. A child's `id` cannot be one of several parts (it names one row alone), and the two sides name as many fields. Where the level's `order` is the index's next parts — in its directions or every one reversed, a part the key fixes ordering nothing — each parent's run is **walked** in that order and stops at the page: "the five latest lines of each order" reads five lines an order, where a bucket is read whole and sorted. Any other order is read and sorted per parent, as for a single key, its ties in ascending id either way. A single field leading an index over several is answered the same way: with no `@hash` of its own, or where the level's order walks the index past it — `lookup reviews on product_id order created desc limit 3` over `@sorted(product_id, created desc)`. `required` takes either side here too: where a part is paired with the parent's `id` and the child filter holds a `@hash` equality, the children it names map back to their parents by that part, each held to the rest of the key by its own parts against its parent's — the same bytes the probe from the parent would find. ### A level that folds ```fenecql get shops limit 20 lookup orders on shop select count(*) as n, sum(total) as revenue get couriers limit 20 lookup deliveries on courier select count(*) as n, avg(km) as km order at desc limit 50 get shops lookup lines on shop select product, sum(qty) as sold group product order sold desc limit 3 get orders select courier, count(*) as n where at >= $1 group courier order n desc limit 10 lookup couriers on user = courier select name ``` ```js await db.from('shops') .limit(20) .lookup( 'orders', { on: 'shop', select: [expr('count(*)').as('n'), expr('sum(total)').as('revenue')] } ) .rows(); await db.from('couriers') .limit(20) .lookup( 'deliveries', { on: 'courier', select: [expr('count(*)').as('n'), expr('avg(km)').as('km')], order: [['at', 'desc']], limit: 50 } ) .rows(); await db.from('shops') .lookup( 'lines', { on: 'shop', select: ['product', expr('sum(qty)').as('sold')], group: ['product'], order: [['sold', 'desc']], limit: 3 } ) .rows(); await db.from('orders') .select('courier', expr('count(*)').as('n')) .where('at', '>=', '2026-10-01') .group('courier') .order('n', 'desc') .limit(10) .lookup('couriers', { on: 'user', parentKey: 'courier', select: ['name'] }) .rows(); ``` ```python (db.collection("shops") .limit(20) .lookup( "orders", on="shop", select=[expr("count(*)").as_("n"), expr("sum(total)").as_("revenue")] ) .rows()) (db.collection("couriers") .limit(20) .lookup( "deliveries", on="courier", select=[expr("count(*)").as_("n"), expr("avg(km)").as_("km")], order=[("at", "desc")], limit=50 ) .rows()) (db.collection("shops") .lookup( "lines", on="shop", select=["product", expr("sum(qty)").as_("sold")], group=["product"], order=[("sold", "desc")], limit=3 ) .rows()) (db.collection("orders") .select("courier", expr("count(*)").as_("n")) .where("at", ">=", "2026-10-01") .group("courier") .order("n", "desc") .limit(10) .lookup("couriers", on="user", parent_key="courier", select=["name"]) .rows()) ``` ```go db.From("shops"). Limit(20). Lookup( "orders", fenecdb.On("shop"), fenecdb.Select(fenecdb.Expr("count(*)").As("n"), fenecdb.Expr("sum(total)").As("revenue")), ). Rows(ctx) db.From("couriers"). Limit(20). Lookup( "deliveries", fenecdb.On("courier"), fenecdb.Select(fenecdb.Expr("count(*)").As("n"), fenecdb.Expr("avg(km)").As("km")), fenecdb.Sort("at", "desc"), fenecdb.Limit(50), ). Rows(ctx) db.From("shops"). Lookup( "lines", fenecdb.On("shop"), fenecdb.Select("product", fenecdb.Expr("sum(qty)").As("sold")), fenecdb.Group("product"), fenecdb.Sort("sold", "desc"), fenecdb.Limit(3), ). Rows(ctx) db.From("orders"). Select("courier", fenecdb.Expr("count(*)").As("n")). Where("at", ">=", "2026-10-01"). Group("courier"). Order("n", "desc"). Limit(10). Lookup( "couriers", fenecdb.On("user"), fenecdb.ParentKey("courier"), fenecdb.Select("name"), ). Rows(ctx) ``` ```csharp await db.From("shops") .Limit(20) .Lookup( "orders", on: "shop", select: [Computed.Expr("count(*)").As("n"), Computed.Expr("sum(total)").As("revenue")] ) .RowsAsync(); await db.From("couriers") .Limit(20) .Lookup( "deliveries", on: "courier", select: [Computed.Expr("count(*)").As("n"), Computed.Expr("avg(km)").As("km")], order: [new SortKey("at", "desc")], limit: 50 ) .RowsAsync(); await db.From("shops") .Lookup( "lines", on: "shop", select: ["product", Computed.Expr("sum(qty)").As("sold")], group: ["product"], order: [new SortKey("sold", "desc")], limit: 3 ) .RowsAsync(); await db.From("orders") .Select("courier", Computed.Expr("count(*)").As("n")) .Where("at", ">=", "2026-10-01") .Group("courier") .Order("n", "desc") .Limit(10) .Lookup("couriers", on: "user", parentKey: "courier", select: ["name"]) .RowsAsync(); ``` ```swift try await db.from("shops") .limit(20) .lookup( "orders", on: "shop", select: [.expr("count(*)").as("n"), .expr("sum(total)").as("revenue")] ) .rows() try await db.from("couriers") .limit(20) .lookup( "deliveries", on: "courier", select: [.expr("count(*)").as("n"), .expr("avg(km)").as("km")], order: [SortKey("at", "desc")], limit: 50 ) .rows() try await db.from("shops") .lookup( "lines", on: "shop", select: ["product", .expr("sum(qty)").as("sold")], group: ["product"], order: [SortKey("sold", "desc")], limit: 3 ) .rows() try await db.from("orders") .select("courier", .expr("count(*)").as("n")) .where("at", ">=", "2026-10-01") .group("courier") .order("n", "desc") .limit(10) .lookup("couriers", on: "user", parentKey: "courier", select: ["name"]) .rows() ``` ```kotlin db.from("shops") .limit(20) .lookup( "orders", on = "shop", select = listOf(Computed.expr("count(*)").alias("n"), Computed.expr("sum(total)").alias("revenue")) ) .rows() db.from("couriers") .limit(20) .lookup( "deliveries", on = "courier", select = listOf(Computed.expr("count(*)").alias("n"), Computed.expr("avg(km)").alias("km")), order = listOf(SortKey("at", "desc")), limit = 50 ) .rows() db.from("shops") .lookup( "lines", on = "shop", select = listOf("product", Computed.expr("sum(qty)").alias("sold")), group = listOf("product"), order = listOf(SortKey("sold", "desc")), limit = 3 ) .rows() db.from("orders") .select("courier", Computed.expr("count(*)").alias("n")) .where("at", ">=", "2026-10-01") .group("courier") .order("n", "desc") .limit(10) .lookup( "couriers", on = "user", parentKey = "courier", select = listOf("name") ) .rows() ``` ```dart await db.from('shops') .limit(20) .lookup( 'orders', on: 'shop', select: [Computed.expr('count(*)').as('n'), Computed.expr('sum(total)').as('revenue')] ) .rows(); await db.from('couriers') .limit(20) .lookup( 'deliveries', on: 'courier', select: [Computed.expr('count(*)').as('n'), Computed.expr('avg(km)').as('km')], order: [SortKey('at', 'desc')], limit: 50 ) .rows(); await db.from('shops') .lookup( 'lines', on: 'shop', select: ['product', Computed.expr('sum(qty)').as('sold')], group: ['product'], order: [SortKey('sold', 'desc')], limit: 3 ) .rows(); await db.from('orders') .select(['courier', Computed.expr('count(*)').as('n')]) .where('at', '>=', '2026-10-01') .group('courier') .order('n', 'desc') .limit(10) .lookup('couriers', on: 'user', parentKey: 'courier', select: ['name']) .rows(); ``` ```fenecql get shops limit $1 lookup orders on shop select count(*) as n, sum(total) as revenue -- [20] get couriers limit $1 lookup deliveries on courier select count(*) as n, avg(km) as km order at desc limit $2 -- [20, 50] get shops lookup lines on shop select product, sum(qty) as sold group product order sold desc limit $1 -- [3] get orders select courier, count(*) as n where at >= $1 group courier order n desc limit $2 lookup couriers on user = courier select name -- ["2026-10-01", 10] ``` A level's list may aggregate, as a `get`'s does, by the same rules: each parent's children — probed once, as any level's are, held to its `where` — folded into one row, or with `group` into one row a group, ordered and paged a parent at a time. A level is the `get` its children would be with the parent's key in its `where`, and its clauses mean what they mean there: ungrouped, `order`, `limit` and `offset` pick the children folded — a courier's latest 50, walked off `@sorted(courier, at desc)` as far as the page and no further — and grouped they order and page the groups, a shop's three best sellers, a window function's question in SQL. A parent with no child gets the one row every ungrouped list answers, `count` 0 and a sum null, and no group. A `get` whose list aggregates takes a `lookup` too: it hangs off the rows the list answers, its groups, and `on child = parent` names a column of the list — the busiest ten couriers with their names, above, where the list named the courier. The same holds below a level that folds. The positional rule does not move: the names on the left are the looked-up collection's, those on the right the level immediately above's, and that level's rows are its groups. What stays out is a list of two collections: grouping parents by a field of theirs and folding their children's — revenue by the shops' city — would put a parent's name and a child's beside each other in one list, which is the qualified name the scoping keeps out. Fold the level a parent at a time and group the parents in the client, or keep the field on the child. `required` on a level that folds asks of its children, as on any level: an ungrouped list answers a row for every parent, so asked of the rows it would keep them all, and "couriers who delivered, with their counts" is the question. A `required` level below one that folds drops its groups. Over 2 000 couriers and 200 000 deliveries, a page of the 20 busiest with the count and mean of each one's latest 50 takes 53 µs in process, against 81 for the 50 attached and folded by the client; every delivery of each folded, 3.5 to 4.1 ms against 6.4 to 6.5; the ten busiest with their names off a week's groups 8.3 ms, as the groups and a query for the names apart do, less the round trip between them (`make analytics-bench`). ```fenecql get products limit 20 lookup reviews on product_id required where stars = 5 get products count lookup reviews on product_id required where stars = 5 ``` ```js await db.from('products') .limit(20) .lookup('reviews', { on: 'product_id', required: true, where: { stars: 5 } }) .rows(); await db.from('products') .lookup('reviews', { on: 'product_id', required: true, where: { stars: 5 } }) .count(); ``` ```python (db.collection("products") .limit(20) .lookup("reviews", on="product_id", required=True, where={"stars": 5}) .rows()) (db.collection("products") .lookup("reviews", on="product_id", required=True, where={"stars": 5}) .count()) ``` ```go db.From("products"). Limit(20). Lookup( "reviews", fenecdb.On("product_id"), fenecdb.Required(), fenecdb.Where(fenecdb.Fields("stars", 5)), ). Rows(ctx) db.From("products"). Lookup( "reviews", fenecdb.On("product_id"), fenecdb.Required(), fenecdb.Where(fenecdb.Fields("stars", 5)), ). Count(ctx) ``` ```csharp await db.From("products") .Limit(20) .Lookup( "reviews", on: "product_id", required: true, where: Cond.Cmp("stars", "=", 5) ) .RowsAsync(); await db.From("products") .Lookup( "reviews", on: "product_id", required: true, where: Cond.Cmp("stars", "=", 5) ) .CountAsync(); ``` ```swift try await db.from("products") .limit(20) .lookup("reviews", on: "product_id", where: ["stars": 5], required: true) .rows() try await db.from("products") .lookup("reviews", on: "product_id", where: ["stars": 5], required: true) .count() ``` ```kotlin db.from("products") .limit(20) .lookup( "reviews", on = "product_id", required = true, where = mapOf("stars" to 5) ) .rows() db.from("products") .lookup( "reviews", on = "product_id", required = true, where = mapOf("stars" to 5) ) .count() ``` ```dart await db.from('products') .limit(20) .lookup('reviews', on: 'product_id', required: true, where: {'stars': 5}) .rows(); await db.from('products') .lookup('reviews', on: 'product_id', required: true, where: {'stars': 5}) .count(); ``` ```fenecql get products limit $1 lookup reviews on product_id required where stars = $2 -- [20, 5] get products count lookup reviews on product_id required where stars = $1 -- [5] ``` `required` drops a parent that no child matches — "products that have a five-star review" rather than "products, with their five-star reviews". It is tested before `offset` and `limit`, so a page of twenty comes back with twenty parents that have children rather than whatever survived; a child `offset` that walks past every match does not undo it, because the parent has matches all the same. It is the one shape where `count` combines with `lookup`: nothing is being attached, so counting the parents that have a match is a question with an answer. **`required` is a pass, and it is not cheap.** It is answered from whichever side is smaller: walking the candidate parents and probing each, or — when the child filter carries a `@hash` equality and the parent's key is `id` — reading the children that equality names and taking their parents. Both counts that decide are exact, so the choice is not a guess; what neither plan escapes, once the page is put in order or a facet counts every parent, is that one full pass has to happen. With no `order` the page is the first parents kept, and walking the parents stops once it holds them. Over 20 000 products and 200 000 reviews, for a page of twenty: | `required`, child filter on a `@hash` field | 12.07 ms | | the same, walking the parents instead | 15.63 ms | | `required`, no child filter, no `order` | 0.036 ms | | `count … required` | 4.50 ms | | a `bool @hash` field on the parent | 0.02 ms | Ask it occasionally and `required` is the right tool. Ask it on every page load and the answer belongs in a field on the parent, maintained on write — which is the same trade the rest of this database makes. In a chain, `required` is a statement about its own level: it drops rows of the level immediately above it and stops there. Requiring lines drops the orders that have none; the shops keep their rows, with an empty group, unless the orders level says `required` as well. Said at every level it composes into "shops with an order that has a line", and the existence check walks the whole way down — a row is only kept if it has a descendant that is itself kept. Nothing acts at a distance, and the flag means the same thing wherever it is written. `lookup` combines with neither `near`, `match` nor `rerank`: each ranks one collection's documents, and a score spanning a parent and its children has no defensible meaning. `count` is refused as well unless the clause is `required`, since otherwise it collapses the very rows the children would hang from. `order` inside a `lookup` is bounded in advance: the clause carries its own `limit`, so only `offset + limit` children can ever be emitted and the rest are never put in order. On a parent with 56 374 children, `order created desc limit 3` costs 3.71 ms rather than 7.52 ms. What remains is reading one key per child, which no ordering can avoid — you cannot know the first three without having seen all of them. The query's own `order` works the same way. Over JSON the children are nested under the collection's name, and a chain nests all the way down. Flattened into rows, for a caller that wants a table, they arrive the way a join presents them — the same data; a chain widens once per level, one row per root-to-leaf path, and a level that ran out fills its own columns and every column below it with nulls. A parent with no matches keeps its row either way: the page is the parents. What the flat shape cannot show is the per-level `limit`, which is exactly why the nesting is what the other transports carry. **What it buys, measured** (5 000 products, 100 007 reviews, twenty parents with three children each): the clause is **22.7 µs** in process and **0.188 ms** over HTTP, against **0.332 ms** for the same work as a `/batch` of twenty-one queries merged on the client. The larger gain is that there is one way to write it. The same question in SQLite, over the same data: a correlated subquery takes 0.053 ms, a window function with the restriction pushed inside the partition 0.331 ms, and the same window function with the restriction left outside **81.34 ms** — a factor of 1 500 between three spellings of one question, with nothing in the query text to tell them apart. A second level is where batching stops being an answer. One level of N+1 fits in a single round trip, because every query in it can be written from the page's ids; the level below cannot, because its keys are in rows that have not come back yet — so the client pays two round trips whatever it does. Over 2 000 shops, 20 000 orders and 200 000 lines, for a page of 20 × 3 × 5: **49.1 µs** in process against **139.8 µs** for the same page as 81 separate queries, and **0.204 ms** for the one HTTP request against **0.415 ms** for two `/batch` trips — measured on loopback, where the extra trip costs almost nothing and still doubles it. ## Subqueries `in (get …)` filters by rows of another collection: the reverse of `lookup`. The inner `get` runs once, before the query, and the one column it selects is the list. ```fenecql get orders where customer in (get customers select id where country = "TR") ``` - **It answers as its list written out.** One bucket per value over a `@hash` field or `id`, a scan otherwise — the `in [..]` you would have written. - **The inner `get` selects exactly one column.** A field, or one aggregate; no `lookup` and no `count`. - **It goes wherever a filter goes**: `get`, `count`, aggregates, a `near`'s or a `match`'s filter, a `lookup` level's, `set … where` and `del … where`. - **At most 100 000 distinct values** and 4 levels of nesting; past either the query is an error, never a set cut short. A value held by many rows counts once. Why an inner get is uncorrelated, and its cost ```fenecql get orders where status = "open" and customer in (get customers select id where tier >= $1) get orders where not customer in (get customers select id where country = "TR") count del sessions where user in (get users select name where banned = true) get customers limit 20 lookup orders on customer where id in (get returns select order_id) limit 3 get orders where customer in (get customers select id where tier in (get tiers select n where active = true)) ``` It is uncorrelated: the inner `get` reads nothing of the row being tested, so it runs once, with the same parameters as the query around it — `$1` inside is the query's `$1`. Its column is a field, or one aggregate (`get orders select max(total)`); a `null` in it is left out of the list, since it would otherwise find the rows whose field is null, and `not … in` would lose every row. `not` is FenecQL's own `not`, written before: `not customer in (get …)`; `customer not in` is not FenecQL. The values become the list an `in [..]` is answered with, and the same rule holds: a list reaches the `@hash` index only when every value is one the field's type can express, or the whole list goes to the scan. A long list is looked up rather than walked — a scan of 200 000 rows by an unindexed field against the 2 000 codes an inner `get` handed it took 2 302 ms compared a value at a time and takes 16. The bound is on the values the list holds, not on the rows that hold them: a funnel's 125 000 `buy` events from 937 users is a list of 937. Past 100 000 rows the values are told apart, and the inner `get` read until they number more than 100 000; with a `limit`, `group` or ranking it is read one row past the bound, and a `limit` inside is the query's own cut, and answers. `explain` names what each inner `get` found. Over HTTP a scoped token's rules hold the inner `get` as they hold any `get`: the list holds only rows the token may read, so nothing of the others reaches it through what the outer query finds, and a column its rules show on some rows alone is listed from those rows ([Fields a read shows](https://fenecdb.com/docs/http-access#field-grants)). A token's rule may hold one too, held to 1 000 values and answered before each statement ([Scoped access](https://fenecdb.com/docs/http-access#scoped-access)). The JavaScript builder takes a query as an `in`'s value: `{ customer: { in: db.from('customers').select('id').where({ country: 'TR' }) } }`. A subscription's shape takes a list only — its stream follows its own collection's writes, and an inner set would not follow the other's. **What it costs, measured** (`make subquery-bench`, 20 000 customers and 200 000 orders, the median of 21). The orders of one country's 2 000 customers by the `@hash` on `customer`: 0.83 ms, against 0.72 ms with the 2 000 ids written out; by an unindexed code, 16.1 ms against 15.9. The customers with a rare order — the inner `get` reading a bucket — 0.20 ms against 0.065 written out and 0.44 for `lookup orders on customer required where status = "rare"`, the same question asked from the other side; with a filter on the customers as well, 0.56 against `lookup`'s 0.95. Where the inner `get` scans 200 000 orders it is that scan, 10.4 ms, and `lookup … required` probes each customer's orders instead, 32.5. The difference to the list written out is the inner `get`: a subquery is one round trip where the list is two. --- Source: https://fenecdb.com/docs/fenecql-aggregates # Aggregates and grouping Totals, counts and groups in the select list, and buckets of time. Put `count`, `sum`, `avg`, `min`, `max`, `first` or `last` in the select list to fold the rows a query finds into one, and add `group` for one row per distinct set of keys. Run in [the shell](https://fenecdb.com/docs/shell#quickstart), answers and all; the timings are from one run. ```text fenec> create collection sales (region text, qty int, price int, at timestamp); collection `sales` created fenec> put sales [{region: "north", qty: 2, price: 500, at: "2026-05-03T09:10:00Z"}, {region: "north", qty: 1, price: 900, at: "2026-05-03T09:40:00Z"}, {region: "south", qty: 4, price: 300, at: "2026-05-03T10:05:00Z"}, {region: "south", qty: 3, price: 350, at: "2026-05-03T10:50:00Z"}, {region: "north", qty: 5, price: 400, at: "2026-05-03T11:15:00Z"}]; 5 records (196.33µs) fenec> get sales select count(*) as n, sum(qty) as units, max(price) as top n units top - ----- --- 5 15 900 (1 rows, 152.33µs) fenec> get sales select region, count(*) as n, sum(qty * price) as revenue group region order revenue desc region n revenue ------ - ------- north 3 3900 south 2 2250 (2 rows, 171.04µs) fenec> get sales select bucket(at, 1h) as hour, sum(qty) as units group hour hour units -------------------- ----- 2026-05-03T09:00:00Z 3 2026-05-03T10:00:00Z 7 2026-05-03T11:00:00Z 5 (3 rows, 149.96µs) fenec> get sales select region, sum(qty) as units group region having sum(qty) >= 8 region units ------ ----- north 8 (1 rows, 168.46µs) ``` An aggregate folds any expression, `sum(qty * price)`; `bucket(at, 1h)` truncates a timestamp to the hour it falls in, and its intervals and time zones are under [Functions over a row](https://fenecdb.com/docs/fenecql-reading#functions); and `having` keeps the groups it holds for. Bars, VWAP, distinct users and funnels are on [Analytics and market data](https://fenecdb.com/docs/analytics). ## Aggregates Put `count(*)`, `count(…)`, `count(distinct …)`, `sum`, `avg`, `min`, `max`, `first` or `last` in the select list to get totals. Add `group` for one row per distinct set of its keys' values; a list of the keys alone, `select city group city`, answers each set once, as SQL's `select distinct city` does. ```fenecql get orders select count(*), sum(total), avg(total), min(at), max(at) get orders select status, count(*), avg(total) where year = 2026 group status get orders select customer, sum(total) group customer order sum(total) desc limit 10 get events select bucket(at, 1h) as hour, name, count(distinct user) as users group hour, name get ticks select bucket(at, 1m) as bar, first(px by at) as open, max(px) as high, min(px) as low, last(px by at) as close, sum(qty) as volume where sym = $1 group bar get ticks select sym, sum(px * qty) / sum(qty) as vwap group sym get events select props.plan, sum(props.v) group props.plan ``` ```js await db.from('orders') .select('count(*)', 'sum(total)', 'avg(total)', 'min(at)', 'max(at)') .rows(); await db.from('orders') .select('status', 'count(*)', 'avg(total)') .where('year', 2026) .group('status') .rows(); await db.from('orders') .select('customer', 'sum(total)') .group('customer') .order('sum(total)', 'desc') .limit(10) .rows(); await db.from('events') .select( bucket('at', '1h').as('hour'), 'name', countDistinct('user').as('users') ) .group('hour', 'name') .rows(); await db.from('ticks') .select( bucket('at', '1m').as('bar'), first('px', 'at').as('open'), expr('max(px)').as('high'), expr('min(px)').as('low'), last('px', 'at').as('close'), expr('sum(qty)').as('volume') ) .where('sym', 'S07') .group('bar') .rows(); await db.from('ticks') .select('sym', expr('sum(px * qty) / sum(qty)').as('vwap')) .group('sym') .rows(); await db.from('events') .select('props.plan', 'sum(props.v)') .group('props.plan') .rows(); ``` ```python (db.collection("orders") .select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)") .rows()) (db.collection("orders") .select("status", "count(*)", "avg(total)") .where("year", 2026) .group("status") .rows()) (db.collection("orders") .select("customer", "sum(total)") .group("customer") .order("sum(total)", "desc") .limit(10) .rows()) (db.collection("events") .select( bucket("at", "1h").as_("hour"), "name", count_distinct("user").as_("users") ) .group("hour", "name") .rows()) (db.collection("ticks") .select( bucket("at", "1m").as_("bar"), first("px", "at").as_("open"), expr("max(px)").as_("high"), expr("min(px)").as_("low"), last("px", "at").as_("close"), expr("sum(qty)").as_("volume") ) .where("sym", "S07") .group("bar") .rows()) (db.collection("ticks") .select("sym", expr("sum(px * qty) / sum(qty)").as_("vwap")) .group("sym") .rows()) (db.collection("events") .select("props.plan", "sum(props.v)") .group("props.plan") .rows()) ``` ```go db.From("orders"). Select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)"). Rows(ctx) db.From("orders"). Select("status", "count(*)", "avg(total)"). Where("year", "=", 2026). Group("status"). Rows(ctx) db.From("orders"). Select("customer", "sum(total)"). Group("customer"). Order("sum(total)", "desc"). Limit(10). Rows(ctx) db.From("events"). Select( fenecdb.Bucket("at", "1h").As("hour"), "name", fenecdb.CountDistinct("user").As("users"), ). Group("hour", "name"). Rows(ctx) db.From("ticks"). Select( fenecdb.Bucket("at", "1m").As("bar"), fenecdb.First("px", "at").As("open"), fenecdb.Expr("max(px)").As("high"), fenecdb.Expr("min(px)").As("low"), fenecdb.Last("px", "at").As("close"), fenecdb.Expr("sum(qty)").As("volume"), ). Where("sym", "=", "S07"). Group("bar"). Rows(ctx) db.From("ticks"). Select("sym", fenecdb.Expr("sum(px * qty) / sum(qty)").As("vwap")). Group("sym"). Rows(ctx) db.From("events"). Select("props.plan", "sum(props.v)"). Group("props.plan"). Rows(ctx) ``` ```csharp await db.From("orders") .Select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)") .RowsAsync(); await db.From("orders") .Select("status", "count(*)", "avg(total)") .Where("year", 2026) .Group("status") .RowsAsync(); await db.From("orders") .Select("customer", "sum(total)") .Group("customer") .Order("sum(total)", "desc") .Limit(10) .RowsAsync(); await db.From("events") .Select( Computed.Bucket("at", "1h").As("hour"), "name", Computed.CountDistinct("user").As("users") ) .Group("hour", "name") .RowsAsync(); await db.From("ticks") .Select( Computed.Bucket("at", "1m").As("bar"), Computed.First("px", "at").As("open"), Computed.Expr("max(px)").As("high"), Computed.Expr("min(px)").As("low"), Computed.Last("px", "at").As("close"), Computed.Expr("sum(qty)").As("volume") ) .Where("sym", "S07") .Group("bar") .RowsAsync(); await db.From("ticks") .Select("sym", Computed.Expr("sum(px * qty) / sum(qty)").As("vwap")) .Group("sym") .RowsAsync(); await db.From("events") .Select("props.plan", "sum(props.v)") .Group("props.plan") .RowsAsync(); ``` ```swift try await db.from("orders") .select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)") .rows() try await db.from("orders") .select("status", "count(*)", "avg(total)") .where("year", 2026) .group("status") .rows() try await db.from("orders") .select("customer", "sum(total)") .group("customer") .order("sum(total)", "desc") .limit(10) .rows() try await db.from("events") .select( .bucket("at", "1h").as("hour"), "name", .countDistinct("user").as("users") ) .group("hour", "name") .rows() try await db.from("ticks") .select( .bucket("at", "1m").as("bar"), .first("px", by: "at").as("open"), .expr("max(px)").as("high"), .expr("min(px)").as("low"), .last("px", by: "at").as("close"), .expr("sum(qty)").as("volume") ) .where("sym", "S07") .group("bar") .rows() try await db.from("ticks") .select("sym", .expr("sum(px * qty) / sum(qty)").as("vwap")) .group("sym") .rows() try await db.from("events") .select("props.plan", "sum(props.v)") .group("props.plan") .rows() ``` ```kotlin db.from("orders") .select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)") .rows() db.from("orders") .select("status", "count(*)", "avg(total)") .where("year", 2026) .group("status") .rows() db.from("orders") .select("customer", "sum(total)") .group("customer") .order("sum(total)", "desc") .limit(10) .rows() db.from("events") .select( Computed.bucket("at", "1h").alias("hour"), "name", Computed.countDistinct("user").alias("users") ) .group("hour", "name") .rows() db.from("ticks") .select( Computed.bucket("at", "1m").alias("bar"), Computed.first("px", "at").alias("open"), Computed.expr("max(px)").alias("high"), Computed.expr("min(px)").alias("low"), Computed.last("px", "at").alias("close"), Computed.expr("sum(qty)").alias("volume") ) .where("sym", "S07") .group("bar") .rows() db.from("ticks") .select("sym", Computed.expr("sum(px * qty) / sum(qty)").alias("vwap")) .group("sym") .rows() db.from("events") .select("props.plan", "sum(props.v)") .group("props.plan") .rows() ``` ```dart await db.from('orders') .select(['count(*)', 'sum(total)', 'avg(total)', 'min(at)', 'max(at)']) .rows(); await db.from('orders') .select(['status', 'count(*)', 'avg(total)']) .where('year', 2026) .group('status') .rows(); await db.from('orders') .select(['customer', 'sum(total)']) .group('customer') .order('sum(total)', 'desc') .limit(10) .rows(); await db.from('events') .select([ Computed.bucket('at', '1h').as('hour'), 'name', Computed.countDistinct('user').as('users') ]) .group(['hour', 'name']) .rows(); await db.from('ticks') .select([ Computed.bucket('at', '1m').as('bar'), Computed.first('px', 'at').as('open'), Computed.expr('max(px)').as('high'), Computed.expr('min(px)').as('low'), Computed.last('px', 'at').as('close'), Computed.expr('sum(qty)').as('volume') ]) .where('sym', 'S07') .group('bar') .rows(); await db.from('ticks') .select(['sym', Computed.expr('sum(px * qty) / sum(qty)').as('vwap')]) .group('sym') .rows(); await db.from('events') .select(['props.plan', 'sum(props.v)']) .group('props.plan') .rows(); ``` ```fenecql get orders select count(*), sum(total), avg(total), min(at), max(at) get orders select status, count(*), avg(total) where year = $1 group status -- [2026] get orders select customer, sum(total) group customer order sum(total) desc limit $1 -- [10] get events select bucket(at, 1h) as hour, name, count(distinct user) as users group hour, name get ticks select bucket(at, 1m) as bar, first(px by at) as open, max(px) as high, min(px) as low, last(px by at) as close, sum(qty) as volume where sym = $1 group bar -- ["S07"] get ticks select sym, sum(px * qty) / sum(qty) as vwap group sym get events select props.plan, sum(props.v) group props.plan ``` - **Nulls are skipped**, as in SQL, by everything but `count(*)`. - **An aggregate folds any expression** — `sum(px * qty)` — and an item can be an expression over aggregates and keys — `sum(px * qty) / sum(qty)`. - **Groups can be ordered and paged** by any column of the list. Without `group`, `order`, `limit` and `offset` pick the rows the one row folds: `get deliveries select count(*), avg(km) where courier = $1 order at desc limit 50` is the latest 50. - **A `lookup` hangs off the groups**, keyed by a column of the list, and a [lookup level's list may aggregate](https://fenecdb.com/docs/fenecql-lookup#lookup-folds), each parent's children folded. - **Aggregates do not combine** with `near` or `match`. Each aggregate, group keys and having A select list with an aggregate in it folds the rows the query finds — through the same indexes any `where` reaches — into one row, or, with `group`, one row per distinct set of the keys' values. Each column answers under its name, in the order the list is written: the name `as` gives it, or for a field or an aggregate of one the text it was written as — `sum(total)`, `count(distinct user)`, `first(px)` (its `by` left out). Anything else needs `as`. - **Keys.** `group` takes one key or several, each a field, a path, or an expression — `group sym, bucket(at, 1m)`. A name the list gives an item with `as` stands for that item, before a field of the same name: `select bucket(at, 1h) as hour, count(*) group hour`. A plain item of the list is a key itself, or is refused as neither aggregated nor grouped by; an expression written out again in `group` is not matched to the list's — name it once, with `as`. - **`count(*)`** counts rows. **`count(distinct e)`** counts the values `e` takes, a null none, two values the same when their encodings are — as `group` tells its keys apart. It is exact: every value is held once a group, up to a million over every group and item of the query — about 70 MB — and a query past that is refused, never counted short. - **`sum` and `avg`** take numbers: an `int` sum is an `int`, and one past 64 bits is an error rather than a wrapped number; a float among the values makes the sum a `float`, and an average is one. **`min` and `max`** take any value with an order — numbers, `timestamp`, `text`, `bool` — a text field's in its collation. A field whose type cannot be folded is refused before any row is read, an expression's value at the first row that cannot. - **`approx_count_distinct(e)`** counts distinct values in a HyperLogLog sketch of 214 registers, 16 KB a group at most, with a standard error of 0.81% at any count and no million to stop at. `hll_accumulate(e)` answers the sketch as bytes, `hll_combine(s)` merges sketches and the function `hll_estimate(s)` reads a count off one, so days kept as sketches count a month. A query's sketches are held to 64 MB. - **`having `** after `group` keeps the groups it holds for, before `order` and `limit`: it reads the keys, the list's columns by name and any aggregate, `group user having count(*) >= 2`. `count` after it answers how many groups passed. - **`first(e by k)` and `last(e by k)`** are the value of `e` on the group's row least, or greatest, by `k` — a bar's open and close are `first(px by at)` and `last(px by at)`, whatever order the ticks were written in. A tie on `k` goes to the row written first for `first` and last for `last`; without `by`, the order is the one the rows were written in. A row whose value or key is null is passed over, as every aggregate passes a null over. The query's `order` orders the groups, not the rows inside them, so it says nothing to these. - **Nulls are skipped**, as SQL skips them, by everything but `count(*)`. Over no rows — or only nulls — a sum, an average, a minimum and a maximum are `null`, not 0. - **Groups** come out by their keys, in the order `group` names them, the null key first, unless `order` names a column of the list — `order sum(total) desc`, `order vwap` — and `limit` and `offset` page them. - **Ungrouped**, a list answers one row, which an order or a page would have nothing to do with; so there `order`, `limit` and `offset` pick the rows it folds, as they pick a `get`'s page — an `@sorted` field's index walked to it — and the one row folds those: `select avg(km) order at desc limit 50` is the mean of the latest 50, what SQL writes as a subquery. Its keys are the rows' values; a column of the list among them is refused. - **A `lookup` after the list** hangs off its rows — the groups, or the one row — and names a column of the list as the parent's key: `get orders select courier, count(*) as n group courier order n desc limit 10 lookup couriers on user = courier select name` is the ten busiest couriers with their names. The column holds a field's values: a key, its `bucket`, its `min`, `max`, `first` or `last`; a sum's or a count's is refused, having no type to hold the child's key to. `required` there drops the groups no child matches, before `order` and `limit`, and `count` after `group` counts those kept. - **What does not combine** is what ranks rows an aggregate collapses: `near` and `match`. **How it runs.** Every item is bound to the collection once a query: each field and path the list reads is decoded in one pass a row, a field folded as it is is read with nothing copied, and a `bucket` over a constant interval works the interval out once. Rows written in time order come a bucket at a time, so the group the row before went to is asked first. A `count(*)` by `bucket` of a `timestamp` or `int` field with an ordered index (`@sorted`, `@ttl`), whose filter is a range of that field and nothing more, reads the index's keys and no row; `explain` says so. A million rows in 100 groups, `select cat, count(*), avg(price) group cat`, took 80 ms in process where PostgreSQL 17 — one worker, as `make pgvector-up` runs it — took 189 ms of execution time on the same rows; the whole set's count, sum, minimum and maximum, 37 ms; the same groups over the tenth a `@sorted` range found, 9.3 ms. The rows are read, not an index: a filter narrows them, nothing precomputes the totals. The questions analytics and market data ask — bars, VWAP, distinct users, counts by the minute — are on Analytics and market data, each with what it costs over a million rows. ## Totals per group ```fenecql get orders select status, count(*), sum(total) where year = 2026 group status order sum(total) desc limit 3 ``` ```js await db.from('orders') .select('status', 'count(*)', 'sum(total)') .where('year', 2026) .group('status') .order('sum(total)', 'desc') .limit(3) .rows(); ``` ```python (db.collection("orders") .select("status", "count(*)", "sum(total)") .where("year", 2026) .group("status") .order("sum(total)", "desc") .limit(3) .rows()) ``` ```go db.From("orders"). Select("status", "count(*)", "sum(total)"). Where("year", "=", 2026). Group("status"). Order("sum(total)", "desc"). Limit(3). Rows(ctx) ``` ```csharp await db.From("orders") .Select("status", "count(*)", "sum(total)") .Where("year", 2026) .Group("status") .Order("sum(total)", "desc") .Limit(3) .RowsAsync(); ``` ```swift try await db.from("orders") .select("status", "count(*)", "sum(total)") .where("year", 2026) .group("status") .order("sum(total)", "desc") .limit(3) .rows() ``` ```kotlin db.from("orders") .select("status", "count(*)", "sum(total)") .where("year", 2026) .group("status") .order("sum(total)", "desc") .limit(3) .rows() ``` ```dart await db.from('orders') .select(['status', 'count(*)', 'sum(total)']) .where('year', 2026) .group('status') .order('sum(total)', 'desc') .limit(3) .rows(); ``` ```fenecql get orders select status, count(*), sum(total) where year = $1 group status order sum(total) desc limit $2 -- [2026, 3] ``` ## Bars by the minute ```fenecql get ticks select bucket(at, 1m) as bar, first(px by at) as open, max(px) as high, min(px) as low, last(px by at) as close, sum(qty) as volume where sym = $1 and at >= $2 group bar ``` ```js await db.from('ticks') .select( bucket('at', '1m').as('bar'), first('px', 'at').as('open'), expr('max(px)').as('high'), expr('min(px)').as('low'), last('px', 'at').as('close'), expr('sum(qty)').as('volume') ) .where('sym', 'S07') .where('at', '>=', '2026-05-03T09:00:00Z') .group('bar') .rows(); ``` ```python (db.collection("ticks") .select( bucket("at", "1m").as_("bar"), first("px", "at").as_("open"), expr("max(px)").as_("high"), expr("min(px)").as_("low"), last("px", "at").as_("close"), expr("sum(qty)").as_("volume") ) .where("sym", "S07") .where("at", ">=", "2026-05-03T09:00:00Z") .group("bar") .rows()) ``` ```go db.From("ticks"). Select( fenecdb.Bucket("at", "1m").As("bar"), fenecdb.First("px", "at").As("open"), fenecdb.Expr("max(px)").As("high"), fenecdb.Expr("min(px)").As("low"), fenecdb.Last("px", "at").As("close"), fenecdb.Expr("sum(qty)").As("volume"), ). Where("sym", "=", "S07"). Where("at", ">=", "2026-05-03T09:00:00Z"). Group("bar"). Rows(ctx) ``` ```csharp await db.From("ticks") .Select( Computed.Bucket("at", "1m").As("bar"), Computed.First("px", "at").As("open"), Computed.Expr("max(px)").As("high"), Computed.Expr("min(px)").As("low"), Computed.Last("px", "at").As("close"), Computed.Expr("sum(qty)").As("volume") ) .Where("sym", "S07") .Where("at", ">=", "2026-05-03T09:00:00Z") .Group("bar") .RowsAsync(); ``` ```swift try await db.from("ticks") .select( .bucket("at", "1m").as("bar"), .first("px", by: "at").as("open"), .expr("max(px)").as("high"), .expr("min(px)").as("low"), .last("px", by: "at").as("close"), .expr("sum(qty)").as("volume") ) .where("sym", "S07") .where("at", ">=", "2026-05-03T09:00:00Z") .group("bar") .rows() ``` ```kotlin db.from("ticks") .select( Computed.bucket("at", "1m").alias("bar"), Computed.first("px", "at").alias("open"), Computed.expr("max(px)").alias("high"), Computed.expr("min(px)").alias("low"), Computed.last("px", "at").alias("close"), Computed.expr("sum(qty)").alias("volume") ) .where("sym", "S07") .where("at", ">=", "2026-05-03T09:00:00Z") .group("bar") .rows() ``` ```dart await db.from('ticks') .select([ Computed.bucket('at', '1m').as('bar'), Computed.first('px', 'at').as('open'), Computed.expr('max(px)').as('high'), Computed.expr('min(px)').as('low'), Computed.last('px', 'at').as('close'), Computed.expr('sum(qty)').as('volume') ]) .where('sym', 'S07') .where('at', '>=', '2026-05-03T09:00:00Z') .group('bar') .rows(); ``` ```fenecql get ticks select bucket(at, 1m) as bar, first(px by at) as open, max(px) as high, min(px) as low, last(px by at) as close, sum(qty) as volume where sym = $1 and at >= $2 group bar -- ["S07", "2026-05-03T09:00:00Z"] ``` --- Source: https://fenecdb.com/docs/fenecql-search # Text search Ranked words, typos, highlights and facets, then words and meaning in one ranking. `match` ranks a collection's documents by how well their text matches your words, and the rest of a search box hangs off it: `fuzzy` and `prefix` for typos and words begun, `highlight()` and `snippet()` for where the words are, and `facet` for counts beside the page. Run in [the shell](https://fenecdb.com/docs/shell#quickstart), answers and all; the timings are from one run. ```text fenec> create collection articles (title text @text, body text @text, year int @sorted, topic text @hash); collection `articles` created fenec> put articles [{title: "Ownership in Rust", body: "Ownership and borrowing keep memory safe without a garbage collector", year: 2023, topic: "rust"}, {title: "A tour of borrowing", body: "Borrowing lets a function read a value without taking ownership of it", year: 2024, topic: "rust"}, {title: "Garbage collection pauses", body: "A collector that pauses the program trades latency for convenience", year: 2021, topic: "runtime"}, {title: "Indexes for search", body: "An inverted index maps each word to the documents that hold it", year: 2024, topic: "search"}]; 4 records (143.88µs) fenec> get articles select title match body "borrowing" title _score ------------------- ------ Ownership in Rust 0.7053 A tour of borrowing 0.6814 (2 rows, 377.67µs) fenec> get articles select title where year >= 2024 match body "ownership" title _score ------------------- ------ A tour of borrowing 0.6814 (1 rows, 503.92µs) fenec> get articles select title match title "ownrship" fuzzy title _score ----------------- ------ Ownership in Rust 1.2218 (1 rows, 345.96µs) fenec> get articles select title, highlight(body) match body "borrowing" title highlight(body) _score ------------------- --------------- ------ Ownership in Rust [[14, 23]] 0.7053 A tour of borrowing [[0, 9]] 0.6814 (2 rows, 450.00µs) fenec> get articles select title match body "collector" facet topic title _score ------------------------- ------ Ownership in Rust 0.7053 Garbage collection pauses 0.7053 (2 rows, 432.21µs) facet topic: runtime 1, rust 1 ``` `match` needs a `@text` index and adds a `_score` column. A `where` narrows the rows while they are ranked; `fuzzy` finds `ownrship`'s word; `highlight` answers the offsets of the words in a row's text; and `facet` counts the values of a field over every matched row, beside the rows (the shell prints them on the last line). Words and meaning in one ranking, `fuse`, are under [Hybrid search](#hybrid); the vectors are on [Vector search](https://fenecdb.com/docs/fenecql-vector). ## The text clause `match` ranks documents by how well their text matches your words. `rerank` then reorders the best of them by vector distance. ```fenecql get articles match body $1 limit 10 get articles where year >= 2024 match body $1 limit 10 get articles match body $1 rerank embed $2 candidates 500 limit 10 ``` ```js await db.from('articles').match('body', 'ownership').limit(10).rows(); await db.from('articles') .where('year', '>=', 2024) .match('body', 'ownership') .limit(10) .rows(); await db.from('articles') .match('body', 'ownership') .rerank('embed', [0.9, 0.1, 0], { candidates: 500 }) .limit(10) .rows(); ``` ```python db.collection("articles").match("body", "ownership").limit(10).rows() (db.collection("articles") .where("year", ">=", 2024) .match("body", "ownership") .limit(10) .rows()) (db.collection("articles") .match("body", "ownership") .rerank("embed", [0.9, 0.1, 0], candidates=500) .limit(10) .rows()) ``` ```go db.From("articles").Match("body", "ownership").Limit(10).Rows(ctx) db.From("articles"). Where("year", ">=", 2024). Match("body", "ownership"). Limit(10). Rows(ctx) db.From("articles"). Match("body", "ownership"). Rerank("embed", []float64{0.9, 0.1, 0}, fenecdb.Candidates(500)). Limit(10). Rows(ctx) ``` ```csharp await db.From("articles") .Match("body", "ownership") .Limit(10) .RowsAsync(); await db.From("articles") .Where("year", ">=", 2024) .Match("body", "ownership") .Limit(10) .RowsAsync(); await db.From("articles") .Match("body", "ownership") .Rerank("embed", new[] { 0.9, 0.1, 0 }, candidates: 500) .Limit(10) .RowsAsync(); ``` ```swift try await db.from("articles") .match("body", "ownership") .limit(10) .rows() try await db.from("articles") .where("year", ">=", 2024) .match("body", "ownership") .limit(10) .rows() try await db.from("articles") .match("body", "ownership") .rerank("embed", [0.9, 0.1, 0], candidates: 500) .limit(10) .rows() ``` ```kotlin db.from("articles").match("body", "ownership").limit(10).rows() db.from("articles") .where("year", ">=", 2024) .match("body", "ownership") .limit(10) .rows() db.from("articles") .match("body", "ownership") .rerank("embed", listOf(0.9, 0.1, 0.0), candidates = 500) .limit(10) .rows() ``` ```dart await db.from('articles').match('body', 'ownership').limit(10).rows(); await db.from('articles') .where('year', '>=', 2024) .match('body', 'ownership') .limit(10) .rows(); await db.from('articles') .match('body', 'ownership') .rerank('embed', [0.9, 0.1, 0], candidates: 500) .limit(10) .rows(); ``` ```fenecql get articles match body $1 limit $2 -- ["ownership", 10] get articles where year >= $1 match body $2 limit $3 -- [2024, "ownership", 10] get articles match body $1 rerank embed $2 candidates 500 limit $3 -- ["ownership", [0.9, 0.1, 0], 10] ``` - **`match` needs a `@text` index** and adds a `_score` column. - **`rerank` needs no vector index.** It reads the vectors of the matched rows directly. - **Reranking cannot find what the words missed.** For that, use hybrid search. How match filters, and what rerank is for `match` ranks by BM25 over a `@text` index and adds a `_score` column. Like `near` it decides the ordering, so it combines with neither `order` nor `near` — unless `fuse` says how the two rankings make one (below). A `where` filter is applied while the postings are merged, so it costs no second pass. `rerank` reorders what `match` found by exact vector distance. It needs `match` — there is nothing to reorder without it — but it does **not** need an `@hnsw` index: the vectors are read straight out of the store, so this path builds, holds and validates no graph at all. `candidates` sets how many of the matched rows are rescored (default 200); it is never allowed below `limit + offset`. **The two stages answer different questions.** `match` is recall and knows nothing about meaning; `rerank` is precision but only over what the first stage handed it. Measured on BEIR: on SciFact 50 candidates score nDCG@10 0.656 against 0.645 for a full dense scan of the same vectors, and on FiQA 1 000 candidates come within 0.0001 of the full scan — each while scoring under 2% of the corpus. If the lexical stage cannot find the answer at all, no amount of reranking will. ## Typos and words begun `fuzzy` lets a word of a `match` find the words a typo or two away from it, and `prefix` lets the last word find the words it begins — what a search box needs as someone types into it. ```fenecql get products match title $1 fuzzy limit 10 -- "databse" finds "database" get products match title $1 prefix limit 10 -- "datab" finds "database", "databases" get products match title $1 fuzzy prefix limit 10 -- both, the last word a misspelt beginning get products match title $1 fuzzy 1 limit 10 -- one edit at most ``` - **A word's length decides its edits**: none under 5 characters, one from 5 to 8, two from 9. `fuzzy 1` allows one at most, `fuzzy 0` none. - **A word as typed ranks first.** A document holding the word itself scores more from it than one holding only a word near it. - **A word reaches 10 000 words of the index at most**, and the query is refused past that rather than cut — a prefix of one letter over a large vocabulary is. - **`highlight()` and `snippet()` mark what was found**: `databse` marks `database`. How an edit is counted, and the BEIR results An edit is a letter added, dropped, changed, or two letters next to each other swapped — Damerau's distance in its restricted form, as Lucene, Elasticsearch and Meilisearch count it: the four are what Damerau found four misspellings in five to be, and a swap counted as two edits would put a five-letter word with one out of reach. An edit that touches a word's first letter counts two, as Meilisearch counts it: a first letter is rarely the mistyped one, and letting it be multiplies what is found by words no one meant. The bounds by length are Meilisearch's too: a short word has too many neighbours — `cart` is one letter from `card`, `care`, `cars`, `cat` and `art`. Words are folded as the index folds them before they are compared, so `İSTANBL` finds `istanbul`. Only the last word is a prefix; the words before it are whole. The words a word reaches count as one term of the query, a document's counts of them summed, so BM25's saturation holds over them as over a word written three times, and the term's rarity is theirs together — a rare misspelling in the corpus ranks no higher than the word it misspells. A word one edit away, or one a prefix begins, counts as the word would; two edits away, a quarter as much. Where the index holds the word as typed, its variants together are kept below the least it adds to a document, so a one-word query ranks every document holding it above every document holding only what is near it. Measured on BEIR with one typo put into each query, a word one edit away was the word meant and two edits away as often another word, which the weights follow. The words are found in a sorted copy of the index's words, made the first time a `match` asks for `fuzzy` or `prefix` and kept up by every write after it, as the index is: derived, never in the file. A prefix is a range of it; the words within a few edits are found by walking it as the tree of prefixes it is, giving a branch up once no word below it can be near enough. Over 100 000 documents of 60 000 words, sorting them took 6 ms and 1.3 MB; finding a word's neighbours over FiQA's 135 500 words, 14 µs at one edit and 120 at two, its completions 4 µs. **Measured on BEIR**, nDCG@10 (`make beir`), one realistic typo put into a word of each query — a letter dropped, a key beside it typed as well or instead, or two letters swapped, the first letter 7% of the time: SciFact's queries as written score 0.662 and 0.665 with `fuzzy`; with the typo 0.636, and 0.663 with `fuzzy`. FiQA's 0.232 and 0.233; with the typo 0.213, and 0.229. With each query's last word half typed, `prefix` takes SciFact from 0.616 to 0.655 and FiQA from 0.188 to 0.208, and leaves the queries written whole where they were. See the whole table. ## Hybrid search `fuse` combines a text search and a vector search into one ranking. Use it when some answers share your words and others only your meaning. A filter beside `near` is another thing, though it is also called a hybrid query: [A hybrid query](https://fenecdb.com/docs/fenecql-vector#a-hybrid-query) on Vector search. ```fenecql get articles match body $1 near embed $2 fuse limit 10 get articles where year >= 2024 match body $1 near embed $2 fuse limit 10 get articles match body $1 near embed $2 fuse k 60 candidates 50 limit 10 ``` - **It adds ranks, not scores**, so there is no weight to tune. - **It needs an `@hnsw` index** for the vector side. How fuse adds ranks, and the BEIR results `fuse` lets `match` and `near` rank the same query together. Each side takes its own first `candidates` — 20 unless given, never fewer than `limit + offset` — with the `where` filter applied to both, and a document scores `1 / (k + rank)` from each list it is on, `k` being 60 unless given. The sum is the `_score`; ties go to the lower id. Ranks rather than scores, because the two have no scale in common: a BM25 score grows with the length of the query and the rarity of its words, a cosine distance does neither, and a weighted sum would need its weight tuned again for every corpus. Where `rerank` can only reorder what the words found, `fuse` also takes what they missed — at the price of the `@hnsw` graph `near` walks. **Measured on BEIR**, nDCG@10, all-MiniLM-L6-v2 vectors (the whole table). On SciFact, where a claim shares its words with the evidence, `fuse` scores 0.700 against 0.662 for `match` and 0.645 for `near` alone. On FiQA, where a question shares few words with its answer, it scores 0.366 against 0.232 and 0.366: the weak side does not drag the strong one down. Up to 61 candidates a side at `k = 60`, a document both searches found outranks every document only one found; deeper, agreement in the middle of both lists starts to outvote the top of one, and at 100 a side the two scores fall to 0.688 and 0.359. ## Highlights and snippets `highlight()` and `snippet()` in the select list show where the words of a `match` are in a row's text, so a results page can mark them. ```fenecql get articles select title, highlight(body) match body $1 limit 10 get articles select title, highlight(body, "", "") match body $1 limit 10 get articles select title, snippet(body, 20, "…") match body $1 limit 10 get articles select title, snippet(body, 20, $2, $3, $4) match body $1 limit 10 ``` ```js await db.from('articles') .select('title') .highlight('body') .match('body', 'ownership') .limit(10) .rows(); await db.from('articles') .select('title') .highlight('body', { pre: '', post: '' }) .match('body', 'ownership') .limit(10) .rows(); await db.from('articles') .select('title') .snippet('body', 20, { ellipsis: '…' }) .match('body', 'ownership') .limit(10) .rows(); await db.from('articles') .select('title') .snippet('body', 20, { ellipsis: '…', pre: '', post: '' }) .match('body', 'ownership') .limit(10) .rows(); ``` ```python (db.collection("articles") .select("title") .highlight("body") .match("body", "ownership") .limit(10) .rows()) (db.collection("articles") .select("title") .highlight("body", pre="", post="") .match("body", "ownership") .limit(10) .rows()) (db.collection("articles") .select("title") .snippet("body", 20, ellipsis="…") .match("body", "ownership") .limit(10) .rows()) (db.collection("articles") .select("title") .snippet("body", 20, ellipsis="…", pre="", post="") .match("body", "ownership") .limit(10) .rows()) ``` ```go db.From("articles"). Select("title"). Highlight("body"). Match("body", "ownership"). Limit(10). Rows(ctx) db.From("articles"). Select("title"). Highlight("body", fenecdb.Tags("", "")). Match("body", "ownership"). Limit(10). Rows(ctx) db.From("articles"). Select("title"). Snippet("body", 20, fenecdb.Ellipsis("…")). Match("body", "ownership"). Limit(10). Rows(ctx) db.From("articles"). Select("title"). Snippet("body", 20, fenecdb.Ellipsis("…"), fenecdb.Tags("", "")). Match("body", "ownership"). Limit(10). Rows(ctx) ``` ```csharp await db.From("articles") .Select("title") .Highlight("body") .Match("body", "ownership") .Limit(10) .RowsAsync(); await db.From("articles") .Select("title") .Highlight("body", pre: "", post: "") .Match("body", "ownership") .Limit(10) .RowsAsync(); await db.From("articles") .Select("title") .Snippet("body", 20, ellipsis: "…") .Match("body", "ownership") .Limit(10) .RowsAsync(); await db.From("articles") .Select("title") .Snippet("body", 20, ellipsis: "…", pre: "", post: "") .Match("body", "ownership") .Limit(10) .RowsAsync(); ``` ```swift try await db.from("articles") .select("title") .highlight("body") .match("body", "ownership") .limit(10) .rows() try await db.from("articles") .select("title") .highlight("body", pre: "", post: "") .match("body", "ownership") .limit(10) .rows() try await db.from("articles") .select("title") .snippet("body", 20, ellipsis: "…") .match("body", "ownership") .limit(10) .rows() try await db.from("articles") .select("title") .snippet("body", 20, ellipsis: "…", pre: "", post: "") .match("body", "ownership") .limit(10) .rows() ``` ```kotlin db.from("articles") .select("title") .highlight("body") .match("body", "ownership") .limit(10) .rows() db.from("articles") .select("title") .highlight("body", pre = "", post = "") .match("body", "ownership") .limit(10) .rows() db.from("articles") .select("title") .snippet("body", 20, ellipsis = "…") .match("body", "ownership") .limit(10) .rows() db.from("articles") .select("title") .snippet("body", 20, ellipsis = "…", pre = "", post = "") .match("body", "ownership") .limit(10) .rows() ``` ```dart await db.from('articles') .select(['title']) .highlight('body') .match('body', 'ownership') .limit(10) .rows(); await db.from('articles') .select(['title']) .highlight('body', pre: '', post: '') .match('body', 'ownership') .limit(10) .rows(); await db.from('articles') .select(['title']) .snippet('body', 20, ellipsis: '…') .match('body', 'ownership') .limit(10) .rows(); await db.from('articles') .select(['title']) .snippet('body', 20, ellipsis: '…', pre: '', post: '') .match('body', 'ownership') .limit(10) .rows(); ``` ```fenecql get articles select title, highlight(body) match body $1 limit $2 -- ["ownership", 10] get articles select title, highlight(body, $1, $2) match body $3 limit $4 -- ["", "", "ownership", 10] get articles select title, snippet(body, 20, $1) match body $2 limit $3 -- ["…", "ownership", 10] get articles select title, snippet(body, 20, $1, $2, $3) match body $4 limit $5 -- ["…", "", "", "ownership", 10] ``` - **Without tags you get positions**: `highlight(body)` is a list of `[start, end]` pairs; `snippet(body, 20)` is `{"text": …, "marks": [[start, end], …]}`. - **With tags you get the marked text**, each mark between the two values you pass — literals or parameters. The engine adds no markup of its own and escapes nothing. - **Offsets count UTF-16 code units**, what a JavaScript, Java, Kotlin, C#, Dart or Swift `NSString` index counts: `text.slice(start, end)` is the marked word. - **They need `match`**, and go with `fuse` and `rerank`; a query without `match` has no words to mark, and is refused. How marks are found, and what they cost A row's text is read again at query time by the tokenizer of the `@text` index the `match` searched — the same splitting, the same folding (`İstanbul`, `ISTANBUL` and `istanbul` are one word), the same prefixes and runs of characters — and every span a term of the query was read from is marked. So a mark is exactly what the index matched: a word; the whole word a prefix of `@text(prefix=N)` was cut from (`kitapların` for `kitap`); the characters of a pair or a triple of Han, kana, Hangul or Thai, overlapping pairs made one mark (`東京都` for `東京都`). Nothing is kept in the index for it, and the file does not change. Marks come in order and never overlap; a mark never ends inside a character, and takes the marks that belong to the characters at its ends — a combining accent, a Thai tone mark, the variation selector and keycap after a digit — so tagged text never splits a letter from its accent. Any `text` field can be marked, read by the tokenizer of the field `match` searched: `select highlight(title)` beside `match body $1` marks the title's words the query holds. Under `fuse` a row only `near` found holds no word of the query and has no marks; a null field is `null`. Each item answers under its label, `highlight(body)` or `snippet(body)`, after the fields `select` names, where the list puts it — and after every field with `select *, highlight(body)`. The same item twice is refused: the two columns would answer to one name. A snippet is the window of `N` words holding the most marks, centred on the first and the last of them — the first `N` words when none is marked — each character of a run of Han, kana or Hangul counted a word. The ellipsis, when given, stands for what it leaves out at either end; a window holding the start or the end of the text has none there. Its marks count from the start of its `text`, the ellipsis included. **The text is not escaped.** A page that puts a marked text into HTML lets the text's own `<` through. Build the page from the offsets instead — the text as text nodes, each mark a `` element — or escape the text first and the tags after. Over 100 000 documents of 30 words, a mark costs a row about 1 to 3 µs (2 to 4 for a snippet) on top of the `match`; a query that asks none runs as it did, `match` 0.06 ms either way (`make search-bench`). ## Facets `facet` counts the values of a field over every row the query matches — not only the page — for a filter sidebar: how many results each brand, color or tag has. ```fenecql get products where price < 500 limit 20 facet brand, color get products where price < 500 match title $1 limit 20 facet brand top 10, tags get products where category = "phones" count facet brand get products limit 0 facet meta.origin top 5 ``` ```js await db.from('products') .where('price', '<', 500) .limit(20) .facet('brand') .facet('color') .rows(); await db.from('products') .where('price', '<', 500) .match('title', 'tent') .limit(20) .facet('brand', { top: 10 }) .facet('tags') .rows(); await db.from('products') .where('category', 'phones') .facet('brand') .count(); await db.from('products') .limit(0) .facet('meta.origin', { top: 5 }) .rows(); ``` ```python (db.collection("products") .where("price", "<", 500) .limit(20) .facet("brand") .facet("color") .rows()) (db.collection("products") .where("price", "<", 500) .match("title", "tent") .limit(20) .facet("brand", top=10) .facet("tags") .rows()) (db.collection("products") .where("category", "phones") .facet("brand") .count()) db.collection("products").limit(0).facet("meta.origin", top=5).rows() ``` ```go db.From("products"). Where("price", "<", 500). Limit(20). Facet("brand"). Facet("color"). Rows(ctx) db.From("products"). Where("price", "<", 500). Match("title", "tent"). Limit(20). Facet("brand", fenecdb.Top(10)). Facet("tags"). Rows(ctx) db.From("products"). Where("category", "=", "phones"). Facet("brand"). Count(ctx) db.From("products"). Limit(0). Facet("meta.origin", fenecdb.Top(5)). Rows(ctx) ``` ```csharp await db.From("products") .Where("price", "<", 500) .Limit(20) .Facet("brand") .Facet("color") .RowsAsync(); await db.From("products") .Where("price", "<", 500) .Match("title", "tent") .Limit(20) .Facet("brand", top: 10) .Facet("tags") .RowsAsync(); await db.From("products") .Where("category", "phones") .Facet("brand") .CountAsync(); await db.From("products") .Limit(0) .Facet("meta.origin", top: 5) .RowsAsync(); ``` ```swift try await db.from("products") .where("price", "<", 500) .limit(20) .facet("brand") .facet("color") .rows() try await db.from("products") .where("price", "<", 500) .match("title", "tent") .limit(20) .facet("brand", top: 10) .facet("tags") .rows() try await db.from("products") .where("category", "phones") .facet("brand") .count() try await db.from("products") .limit(0) .facet("meta.origin", top: 5) .rows() ``` ```kotlin db.from("products") .where("price", "<", 500) .limit(20) .facet("brand") .facet("color") .rows() db.from("products") .where("price", "<", 500) .match("title", "tent") .limit(20) .facet("brand", top = 10) .facet("tags") .rows() db.from("products").where("category", "phones").facet("brand").count() db.from("products").limit(0).facet("meta.origin", top = 5).rows() ``` ```dart await db.from('products') .where('price', '<', 500) .limit(20) .facet('brand') .facet('color') .rows(); await db.from('products') .where('price', '<', 500) .match('title', 'tent') .limit(20) .facet('brand', top: 10) .facet('tags') .rows(); await db.from('products') .where('category', 'phones') .facet('brand') .count(); await db.from('products').limit(0).facet('meta.origin', top: 5).rows(); ``` ```fenecql get products where price < $1 limit $2 facet brand, color -- [500, 20] get products where price < $1 match title $2 limit $3 facet brand top $4, tags -- [500, "tent", 20, 10] get products where category = $1 count facet brand -- ["phones"] get products limit $1 facet meta.origin top $2 -- [0, 5] ``` ```json {"rows": [ ... ], "facets": {"brand": [{"value": "acme", "count": 12}, {"value": "nova", "count": 7}], "color": [{"value": "red", "count": 9}, {"value": null, "count": 2}]}} ``` - **Most first**, ties by value; `top N` keeps the commonest `N`. - **A list counts each value it holds**: a row tagged `["sale", "new"]` counts once under each. - **`null` is a value**, so a field's counts add up to the rows. - **They come back beside the rows**, never in one: the answer is `{"rows": [...], "facets": {...}}` when a query asks for facets, and the bare list of rows when it does not. Which rows a facet counts, and the cost The rows counted are the rows the query selects before `offset` and `limit`: those the `where` keeps, and with `match`, those holding a word of the query (`rerank` reorders them and counts the same). A `lookup ... required` decides who is counted, as it decides who is on the page; with `count` the facets go beside the number. `near` ranks every row the filter passes rather than selecting some, so counts beside it would be the filter's alone whatever the vector: a facet with `near` or `fuse` is refused. Ask them without `near`, in the same `/batch`. A field is a field or a path into a `json` one. The counts are `group ... count`'s over the same filter, value for value (`tests/facets.rs` holds them to it), but for a list, counted a value at a time, and a path, which `group` does not read. A `@hash` field of text, int, bool or timestamp is counted from its buckets — over every row a bucket's length, under a filter how many of a bucket's rows the set holds — and anything else reads the field of every matched row. Without `top` a facet answers every value it found, and one holding more than 10 000 is refused rather than cut: a list cut where it happened to stop is a wrong answer believed right. Over 100 000 rows (`make search-bench`): a `@hash` field over every row 0.01 ms, read from every row 6.1 ms (a list of tags 17); under a filter keeping half, 5.8 and 9.3 ms against the filter's own 5.9 ms for a `count`. Beside a `match` reaching about 16 667 rows, two facets took a query from 0.06 to 1.6 ms. ### Counted without its own filter With a brand chosen, a sidebar's "Brand" should still list the other brands to add, each with the count it would have. `disjunctive` counts a facet over the rows the query selects with the filter's own conditions on that field left out; the page, and every other facet, stay the query's. ```fenecql get products where category = "tents" and brand in ["acme", "nova"] and color = "red" order price asc limit 24 facet brand disjunctive, color disjunctive, material ``` ```js await db.from('products') .where('category', 'tents') .where('brand', 'in', ['acme', 'nova']) .where('color', 'red') .order('price') .limit(24) .facet('brand', { disjunctive: true }) .facet('color', { disjunctive: true }) .facet('material') .rows(); ``` ```python (db.collection("products") .where("category", "tents") .where("brand", "in", ["acme", "nova"]) .where("color", "red") .order("price") .limit(24) .facet("brand", disjunctive=True) .facet("color", disjunctive=True) .facet("material") .rows()) ``` ```go db.From("products"). Where("category", "=", "tents"). Where("brand", "in", []any{"acme", "nova"}). Where("color", "=", "red"). Order("price", "asc"). Limit(24). Facet("brand", fenecdb.Disjunctive()). Facet("color", fenecdb.Disjunctive()). Facet("material"). Rows(ctx) ``` ```csharp await db.From("products") .Where("category", "tents") .Where("brand", "in", new object?[] { "acme", "nova" }) .Where("color", "red") .Order("price") .Limit(24) .Facet("brand", disjunctive: true) .Facet("color", disjunctive: true) .Facet("material") .RowsAsync(); ``` ```swift try await db.from("products") .where("category", "tents") .where("brand", "in", ["acme", "nova"]) .where("color", "red") .order("price") .limit(24) .facet("brand", disjunctive: true) .facet("color", disjunctive: true) .facet("material") .rows() ``` ```kotlin db.from("products") .where("category", "tents") .where("brand", "in", listOf("acme", "nova")) .where("color", "red") .order("price") .limit(24) .facet("brand", disjunctive = true) .facet("color", disjunctive = true) .facet("material") .rows() ``` ```dart await db.from('products') .where('category', 'tents') .where('brand', 'in', ['acme', 'nova']) .where('color', 'red') .order('price') .limit(24) .facet('brand', disjunctive: true) .facet('color', disjunctive: true) .facet('material') .rows(); ``` ```fenecql get products where category = $1 and brand in [$2, $3] and color = $4 order price asc limit $5 facet brand disjunctive, color disjunctive, material -- ["tents", "acme", "nova", "red", 24] ``` Here `brand` counts over the red tents of every brand, `color` over the acme and nova tents of every colour, and `material` over the page's rows. A condition left out is a term of the `where`'s `and` chain that reads the field alone — `brand = ...`, `brand in [...]`, `(brand = "a" or brand = "b")`, a range — and one reading the field beside another (`brand = "a" or sale`) is refused rather than guessed at. A token's rules and a collection's expiry are never left out: they are ANDed into each facet's own filter too. ### Ranges `ranges [b1, b2, ...]` counts the rows whose number falls in each range — from a bound, included, to the next, excluded — rather than by value: every range, in order, an empty one with 0, its value the `[from, to]` pair. A null, a `NaN` and a number outside every range count in none; a list counts once in each range a value of it falls in. ```fenecql get products where category = "tents" limit 24 facet price ranges [0, 2500, 5000, 10000, 25000] disjunctive ``` ```js await db.from('products') .where('category', 'tents') .limit(24) .facet('price', { ranges: [0, 2500, 5000, 10000, 25000], disjunctive: true }) .rows(); ``` ```python (db.collection("products") .where("category", "tents") .limit(24) .facet("price", ranges=[0, 2500, 5000, 10000, 25000], disjunctive=True) .rows()) ``` ```go db.From("products"). Where("category", "=", "tents"). Limit(24). Facet( "price", fenecdb.Ranges(0, 2500, 5000, 10000, 25000), fenecdb.Disjunctive(), ). Rows(ctx) ``` ```csharp await db.From("products") .Where("category", "tents") .Limit(24) .Facet("price", ranges: [0, 2500, 5000, 10000, 25000], disjunctive: true) .RowsAsync(); ``` ```swift try await db.from("products") .where("category", "tents") .limit(24) .facet("price", ranges: [0, 2500, 5000, 10000, 25000], disjunctive: true) .rows() ``` ```kotlin db.from("products") .where("category", "tents") .limit(24) .facet( "price", ranges = listOf(0, 2500, 5000, 10000, 25000), disjunctive = true ) .rows() ``` ```dart await db.from('products') .where('category', 'tents') .limit(24) .facet('price', ranges: [0, 2500, 5000, 10000, 25000], disjunctive: true) .rows(); ``` ```fenecql get products where category = $1 limit $2 facet price ranges [0, 2500, 5000, 10000, 25000] disjunctive -- ["tents", 24] ``` ```json "price": [{"value": [0, 2500], "count": 31}, {"value": [2500, 5000], "count": 112}, {"value": [5000, 10000], "count": 204}, {"value": [10000, 25000], "count": 96}] ``` The bounds are numbers written out, rising, two to 10 001 of them, and no `top` goes with them. A `@sorted` field counts them from its index when every bound is a key it holds exactly — over every row two searches a range and the lengths of the chunks between, under a filter its entries walked against the filter's rows — and anything else reads the field of every row (`12.5` against an `int` field is read). Ranges and facets, measured on 100 000 rows Over 100 000 rows (`make search-bench`, an M1 Air): six ranges of a `@sorted` price over every row 0.004 ms against 4.7 ms read from every row; under a filter keeping 3%, 0.15 against 0.19 ms; keeping half, 5.1 against 8.1. A category page with a brand and a colour chosen, its page and two disjunctive facets beside a range facet in one statement, took 0.75 ms in process against 0.75 ms for the three statements it was before; over HTTP 0.95 ms against 1.19 for the three in turn and 0.61 for the three at once on three connections. What one statement buys is one request, one connection and counts read under one lock — the same rows every facet counted — not less work. --- Source: https://fenecdb.com/docs/fenecql-vector # Vector search `near` finds the documents whose vector is closest to the one you pass. A `vector` field with an `@hnsw` index holds one embedding a document, and `near` finds the documents whose vector is closest to the one you pass. Run in [the shell](https://fenecdb.com/docs/shell#quickstart), answers and all; the timings are from one run. ```text fenec> create collection places (name text, year int @sorted, embed vector<3> @hnsw(cosine)); collection `places` created fenec> put places [{name: "north", year: 2021, embed: [0, 1, 0]}, {name: "east", year: 2024, embed: [1, 0, 0]}, {name: "up", year: 2024, embed: [0, 0, 1]}, {name: "north-east", year: 2022, embed: [0.7, 0.7, 0]}]; 4 records (264.08µs) fenec> get places select name near embed [0.9, 0.1, 0] limit 2 name _score ---------- ------ east 0.9939 north-east 0.7809 (2 rows, 90.25µs) fenec> get places select name where year >= 2024 near embed [0.9, 0.1, 0] limit 2 name _score ---- ------ east 0.9939 up 0.0000 (2 rows, 199.75µs) fenec> get places select name near embed [0.9, 0.1, 0] exact limit 1 name _score ---- ------ east 0.9939 (1 rows, 99.71µs) ``` The index is `@hnsw(cosine)`, so `_score` is the cosine similarity: `east` points where the query does and `up` is at right angles to it. A `where` narrows the rows searched and `exact` checks every vector instead of walking the graph. A real embedding has hundreds of dimensions, `vector<768>`; three keep the example readable. Words and meaning together, `fuse`, are under [Hybrid search](https://fenecdb.com/docs/fenecql-search#hybrid). ## The vector clause `near` finds the documents whose vector is closest to the one you pass. Add a `where` to search only the rows that match. ```fenecql get articles near embed $1 limit 10 get articles where year >= 2024 near embed $1 ef 200 limit 10 get articles near embed $1 exact limit 10 ``` ```js await db.from('articles').near('embed', [0.9, 0.1, 0]).limit(10).rows(); await db.from('articles') .where('year', '>=', 2024) .near('embed', [0.9, 0.1, 0], { ef: 200 }) .limit(10) .rows(); await db.from('articles') .near('embed', [0.9, 0.1, 0], { exact: true }) .limit(10) .rows(); ``` ```python db.collection("articles").near("embed", [0.9, 0.1, 0]).limit(10).rows() (db.collection("articles") .where("year", ">=", 2024) .near("embed", [0.9, 0.1, 0], ef=200) .limit(10) .rows()) (db.collection("articles") .near("embed", [0.9, 0.1, 0], exact=True) .limit(10) .rows()) ``` ```go db.From("articles"). Near("embed", []float64{0.9, 0.1, 0}). Limit(10). Rows(ctx) db.From("articles"). Where("year", ">=", 2024). Near("embed", []float64{0.9, 0.1, 0}, fenecdb.Ef(200)). Limit(10). Rows(ctx) db.From("articles"). Near("embed", []float64{0.9, 0.1, 0}, fenecdb.Exact()). Limit(10). Rows(ctx) ``` ```csharp await db.From("articles") .Near("embed", new[] { 0.9, 0.1, 0 }) .Limit(10) .RowsAsync(); await db.From("articles") .Where("year", ">=", 2024) .Near("embed", new[] { 0.9, 0.1, 0 }, ef: 200) .Limit(10) .RowsAsync(); await db.From("articles") .Near("embed", new[] { 0.9, 0.1, 0 }, exact: true) .Limit(10) .RowsAsync(); ``` ```swift try await db.from("articles") .near("embed", [0.9, 0.1, 0]) .limit(10) .rows() try await db.from("articles") .where("year", ">=", 2024) .near("embed", [0.9, 0.1, 0], ef: 200) .limit(10) .rows() try await db.from("articles") .near("embed", [0.9, 0.1, 0], exact: true) .limit(10) .rows() ``` ```kotlin db.from("articles") .near("embed", listOf(0.9, 0.1, 0.0)) .limit(10) .rows() db.from("articles") .where("year", ">=", 2024) .near("embed", listOf(0.9, 0.1, 0.0), ef = 200) .limit(10) .rows() db.from("articles") .near("embed", listOf(0.9, 0.1, 0.0), exact = true) .limit(10) .rows() ``` ```dart await db.from('articles').near('embed', [0.9, 0.1, 0]).limit(10).rows(); await db.from('articles') .where('year', '>=', 2024) .near('embed', [0.9, 0.1, 0], ef: 200) .limit(10) .rows(); await db.from('articles') .near('embed', [0.9, 0.1, 0], exact: true) .limit(10) .rows(); ``` ```fenecql get articles near embed $1 limit $2 -- [[0.9, 0.1, 0], 10] get articles where year >= $1 near embed $2 ef 200 limit $3 -- [2024, [0.9, 0.1, 0], 10] get articles near embed $1 exact limit $2 -- [[0.9, 0.1, 0], 10] ``` - **`ef` trades speed for accuracy** for one query. - **`exact` checks every vector**, which is how you measure recall. - **Each row gets a `_score`** column. How ef, exact and a filter are planned `ef` raises the search beam for this query only, trading latency for recall. `exact` replaces the ANN walk with a full scan, which is how you verify recall against ground truth. A query that uses `near` gains a `_score` column. Over a `sparse` field `near` ranks by dot product and is exact already, so it takes no `ef` — see Sparse vectors. A filter next to `near` is planned, not stacked: the filter's rows are read only until it is clear whether there are more of them than the ANN would measure anyway, and the planner picks between searching that set exactly and running the ANN with each candidate tested against the filter. The mechanism, and the fallback that keeps it correct, is in How it works. ## Quantized vector indexes `quant=` makes a vector index smaller by storing a compact code for each vector. Scores stay exact, because the final order uses the full vectors. ```fenecql create collection articles (embed vector<768> @hnsw(cosine, quant=int8)) create index on articles (embed) @hnsw(cosine, quant=bit) ``` - **`int8`** is a quarter of the size; **`bit`** about a thirtieth, and cosine only. What int8 and bit codes hold `quant=` sets what the index holds of each vector: `int8` a byte a component, a quarter of `f32`, and `bit` a bit a component, about a thirtieth: the signs of how far the vector lies from the nearest of 128 centres the index learns from its first 2 048 vectors, which it holds whole until then — cosine only, since the vectors are taken at unit length. The documents keep their vectors whole. The codes find the candidates, the beam's `ef` of them, and the documents' own vectors put those in order, so `_score` is the exact distance. An int8 code knows how far off it can be, so a candidate is read only while it can still make the page; a bit code does not, and the whole beam is read. A filtered set small enough to search without the walk is ranked by its codes the same way, and `exact` reads every vector. Bit codes take a wider beam: `ef_search` is 200 there unless it is named. What they save and what they cost is in [How it works](https://fenecdb.com/docs/concepts#quantized-indexes). ## Sparse vectors `sparse` stores the output of a learned sparse model such as SPLADE. `near` over it finds the documents with the largest dot product, exactly. ```fenecql create collection passages (body text @text, splade sparse<30522> @inverted) put passages {body: "...", splade: "{1037:0.84,2003:1.27,7592:2.1}/30522"} get passages near splade $1 limit 10 get passages match body $1 near splade $2 fuse limit 10 ``` - **It is written as pgvector's `sparsevec`**: `{index:value,...}/N`, indices from 1. - **Index it with `@inverted`.** The sparsevec text form and @inverted `sparse` holds a vector of `N` dimensions by its non-zero entries alone: what a learned sparse model such as SPLADE gives a text, a weight for a few dozen or hundred of its vocabulary's 30 522 words. It is written and read in pgvector's `sparsevec` text form, `{index:value,...}/N` with the indices counted from 1, as a string in JSON and in FenecQL; entries come back in index order and zeros are dropped. `@inverted` indexes it as a word index is indexed, dimension by dimension, and `near` over it returns the documents with the largest dot product — the exact ones, walked with the pruning `match` uses — and ranks only documents sharing at least one dimension with the query. A filter is tested as the lists are merged; `exact` scores every document instead, and `fuse` takes a sparse `near` as it takes a dense one. ## A hybrid query A filter beside `near`. It is not `fuse`, which ranks words and vectors together: that is [Hybrid search](https://fenecdb.com/docs/fenecql-search#hybrid). ```fenecql get articles select title, year where year >= 2024 and tags has "rust" and not (title ~ "draft") near embed $1 ef 128 limit 10 ``` ```js await db.from('articles') .select('title', 'year') .where('year', '>=', 2024) .where('tags', 'has', 'rust') .where(not({ title: { '~': 'draft' } })) .near('embed', [0.9, 0.1, 0], { ef: 128 }) .limit(10) .rows(); ``` ```python (db.collection("articles") .select("title", "year") .where("year", ">=", 2024) .where("tags", "has", "rust") .where(not_({"title": {"~": "draft"}})) .near("embed", [0.9, 0.1, 0], ef=128) .limit(10) .rows()) ``` ```go db.From("articles"). Select("title", "year"). Where("year", ">=", 2024). Where("tags", "has", "rust"). WhereCond(fenecdb.Not(fenecdb.Cmp("title", "~", "draft"))). Near("embed", []float64{0.9, 0.1, 0}, fenecdb.Ef(128)). Limit(10). Rows(ctx) ``` ```csharp await db.From("articles") .Select("title", "year") .Where("year", ">=", 2024) .Where("tags", "has", "rust") .Where(Cond.Not(Cond.Cmp("title", "~", "draft"))) .Near("embed", new[] { 0.9, 0.1, 0 }, ef: 128) .Limit(10) .RowsAsync(); ``` ```swift try await db.from("articles") .select("title", "year") .where("year", ">=", 2024) .where("tags", "has", "rust") .where(.not(.cmp("title", "~", "draft"))) .near("embed", [0.9, 0.1, 0], ef: 128) .limit(10) .rows() ``` ```kotlin db.from("articles") .select("title", "year") .where("year", ">=", 2024) .where("tags", "has", "rust") .where(Cond.not(Cond.cmp("title", "~", "draft"))) .near("embed", listOf(0.9, 0.1, 0.0), ef = 128) .limit(10) .rows() ``` ```dart await db.from('articles') .select(['title', 'year']) .where('year', '>=', 2024) .where('tags', 'has', 'rust') .where(Cond.not(Cond.cmp('title', '~', 'draft'))) .near('embed', [0.9, 0.1, 0], ef: 128) .limit(10) .rows(); ``` ```fenecql get articles select title, year where year >= $1 and tags has $2 and not (title ~ $3) near embed $4 ef 128 limit $5 -- [2024, "rust", "draft", [0.9, 0.1, 0], 10] ``` --- Source: https://fenecdb.com/docs/sql # From SQL The SQL you write by habit, read as the FenecQL it means — and refused, saying what to write, where it has no exact meaning here. FenecQL is not SQL, but the everyday spellings of SQL are FenecQL too. The parser reads `CREATE TABLE`, `INSERT INTO … VALUES`, `UPDATE … SET a = …`, `JOIN`, `IN (…)`, `EXISTS`, `DISTINCT`, `LIKE` and the rest of the tables below into the very statements FenecQL writes, so a text in either reaches the engine the same way — over HTTP, through [MCP](https://fenecdb.com/docs/mcp), in the `fenec` shell, in the page and in an app. There is no SQL mode and no second parser: the two spellings mix in one statement. What would answer otherwise than SQL does — a `RIGHT JOIN`, an aggregate over two tables' rows, a `DEFAULT`, a transaction left open — is refused with the FenecQL that does it, so an agent writing SQL by habit learns the language from its first error. Every SQL text on this page is run by a test (`crates/fenec-core/tests/sql_docs.rs`): the session below statement by statement, each `-- → x` the answer it checks, and each line of the tables held to the FenecQL beside it — the two parse to the same statement — or refused. ## A session written as SQL ```sql CREATE TABLE users (id serial PRIMARY KEY, name text, email citext NOT NULL UNIQUE, age integer, city text, visits integer) CREATE INDEX users_age ON users (age) INSERT INTO users (name, email, age, city) VALUES ('Ada', 'ada@example.com', 36, 'London'), ('Bob', 'bob@example.com', 25, 'Leeds'), ('Cem', 'cem@example.com', 52, 'İzmir') RETURNING name -- → "Ada" SELECT name FROM users WHERE age BETWEEN 25 AND 36 ORDER BY age DESC LIMIT 10 -- → "Ada" SELECT name FROM users WHERE city IN ('Leeds', 'İzmir') AND name LIKE 'C%' -- → "Cem" SELECT count(*) AS n FROM users WHERE email ILIKE '%@EXAMPLE.COM' -- → 3 UPDATE users SET visits = coalesce(visits, 0) + 1 WHERE name = 'Ada' RETURNING visits -- → 1 INSERT INTO users (email, visits) VALUES ('ADA@example.com', 1) ON CONFLICT DO UPDATE SET visits = users.visits + excluded.visits RETURNING visits -- → 2 INSERT INTO users (email) VALUES ('bob@example.com') ON CONFLICT DO NOTHING -- → 0 INSERT INTO users (email, visits) VALUES ('bob@example.com', 1) ON CONFLICT (email) DO UPDATE SET visits = excluded.visits RETURNING visits -- → 1 SELECT name || ' <' || email || '>' AS who FROM users WHERE id IN (SELECT id FROM users WHERE age > 30) ORDER BY id -- → "Ada " SELECT name FROM users WHERE visits IS DISTINCT FROM 2 -- → "Bob" SELECT city, count(*) AS n FROM users GROUP BY 1 ORDER BY 2 DESC, 1 -- → "Leeds" CREATE TABLE orders (id serial PRIMARY KEY, user_id integer NOT NULL, total integer, status text) CREATE INDEX ON orders USING hash (user_id) INSERT INTO orders (user_id, total, status) VALUES (1, 1200, 'paid'), (1, 800, 'open'), (2, 450, 'paid') -- → 3 SELECT u.name, o.total FROM orders o JOIN users u ON u.id = o.user_id WHERE o.status = 'paid' ORDER BY o.total DESC -- → "Ada" SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id ORDER BY u.id DESC -- → "Cem" SELECT count(*) AS n FROM orders o JOIN users u ON u.id = o.user_id WHERE u.city = 'London' -- → 2 SELECT DISTINCT u.city FROM users u JOIN orders o ON o.user_id = u.id ORDER BY u.city -- → "Leeds" SELECT name FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) -- → "Cem" SELECT name FROM users WHERE age = (SELECT max(age) FROM users) -- → "Cem" DELETE FROM users WHERE age NOT BETWEEN 20 AND 40 -- → 1 CREATE TABLE docs (id serial PRIMARY KEY, body text, emb vector(3)) CREATE INDEX ON docs USING hnsw (emb vector_cosine_ops) INSERT INTO docs (body, emb) VALUES ('north', [0, 1, 0]), ('east', [1, 0, 0]), ('up', [0, 0, 1]) -- → 3 SELECT body FROM docs ORDER BY emb <=> '[0.9, 0.1, 0]' LIMIT 1 -- → "east" ``` `email` is `citext`, a `nocase` field, so its `=`, its `@unique` and its `LIKE` compare by the case fold, and `'ADA@example.com'` is Ada's row. Each join reads as the `lookup` it is, or as one table's rows filtered by the other's ([below](#joins)); the last statement is `near`, by the cosine distance the index measures. ## Read as written Each line is the SQL and the FenecQL it is — the same statement, to the last expression. | SQL | FenecQL | Notes | | `INSERT INTO t (a, b) VALUES (1, 'x'), (2, 'y')` | `insert t [{a: 1, b: "x"}, {a: 2, b: "y"}]` | a document a row; the fields are named ([below](#insert)) | | `INSERT INTO t (a) VALUES ($1) RETURNING id, a` | `insert t {a: $1} returning id, a` | the rows written, the ids made among them | | `INSERT INTO t (k, n) VALUES ('a', 1) ON CONFLICT DO NOTHING` | `insert t {k: "a", n: 1} if absent` | passed over where its id or a `@unique` value is held | | `INSERT INTO t (k, n) VALUES ('a', 1) ON CONFLICT DO UPDATE SET n = t.n + excluded.n` | `insert t {k: "a", n: 1} if absent else set {n: n + new.n}` | `excluded.f` is `new.f`, `t.f` the row's `f` ([below](#upsert)) | | `UPDATE t SET a = 5, n = n + 1 WHERE k = 'a' RETURNING *` | `set t {a: 5, n: n + 1} where k = "a" returning *` | | | `UPDATE jobs SET owner = $1 WHERE run_at <= now() ORDER BY run_at LIMIT 1 RETURNING *` | `set jobs {owner: $1} where run_at <= now() order run_at limit 1 returning *` | MySQL's `ORDER BY` and `LIMIT` on a write: a [queue's claim](https://fenecdb.com/docs/queues) | | `DELETE FROM t WHERE k = 'a' RETURNING *` | `del t where k = "a" returning *` | | | `SELECT a, b FROM t WHERE n > 1 ORDER BY a DESC LIMIT 10 OFFSET 20` | `get t select a, b where n > 1 order a desc limit 10 offset 20` | | | `SELECT k, count(*) AS n FROM t GROUP BY k HAVING count(*) > 1` | `get t select k, count(*) as n group k having count(*) > 1` | | | `SELECT * FROM t WHERE x IN (1, 2, 3)` | `get t where x in [1, 2, 3]` | through `@hash` as `in [..]` is | | `SELECT * FROM t WHERE x NOT IN ('a', 'b')` | `get t where not (x in ["a", "b"])` | a null `x` is taken in ([below](#nulls)) | | `SELECT * FROM t WHERE x IN (SELECT y FROM u WHERE z = 1)` | `get t where x in (get u select y where z = 1)` | a [subquery](https://fenecdb.com/docs/fenecql-lookup#subqueries), run once | | `SELECT * FROM t WHERE x IN (SELECT DISTINCT y FROM u)` | `get t where x in (get u select y)` | `DISTINCT` changes nothing there | | `SELECT * FROM t WHERE x BETWEEN 1 AND 3 AND y = 2` | `get t where x >= 1 and x <= 3 and y = 2` | both bounds in; the first `AND` is `BETWEEN`'s | | `SELECT * FROM t WHERE x NOT BETWEEN 1 AND 3` | `get t where not (x >= 1 and x <= 3)` | | | `SELECT * FROM t WHERE name LIKE 'A%'` | `get t where name like "A%"` | `%` any run of characters, `_` one ([below](#like)) | | `SELECT * FROM t WHERE name NOT ILIKE '%!_%' ESCAPE '!'` | `get t where not (name ilike "%!_%" escape "!")` | `ILIKE` by the case fold | | `SELECT * FROM t WHERE x <> 1` | `get t where x != 1` | | | `SELECT * FROM t WHERE x IS DISTINCT FROM $1` | `get t where x != $1` | FenecQL's `=` and `!=` take a null as a value | | `SELECT * FROM t WHERE x IS NOT DISTINCT FROM $1` | `get t where x = $1` | | | `SELECT * FROM t WHERE done IS NOT TRUE` | `get t where not (done = true)` | | | `SELECT count(*), count(age) FROM users` | `get users select count(*), count(age)` | `count(age)` the rows where `age` is not null | | `INSERT INTO t VALUES {a: 1}, {a: 2}` | `insert t [{a: 1}, {a: 2}]` | documents name their fields; `put t {…}, {…}` too | | `SELECT * FROM t WHERE x IS NOT NULL ORDER BY x DESC NULLS LAST` | `get t where x is not null order x desc` | `NULLS` where it says what the order does | | `SELECT given || ' ' || family AS name FROM t` | `get t select given + " " + family as name` | `||` is `+` | | `SELECT * FROM t WHERE name = 'O''Brien'` | `get t where name = "O'Brien"` | `''` is a quote in single quotes | | `UPDATE "t" SET "n" = 1 WHERE id IN (1, 2) RETURNING "n"` | `set t {n: 1} where id in [1, 2] returning n` | a quoted name where only a name can stand ([below](#quotes)) | | `SELECT count(DISTINCT u), max(at) FROM t WHERE at >= $1` | `get t select count(distinct u), max(at) where at >= $1` | | | `SELECT a, CASE WHEN n > 1 THEN 'big' ELSE 'small' END AS size FROM t` | `get t select a, case when n > 1 then "big" else "small" end as size` | | | `SELECT u.name FROM users AS u WHERE u.age > 30` | `get users select name where age > 30` | an alias, and the names it qualifies, written bare | | `SELECT DISTINCT city FROM users ORDER BY city` | `get users select city group city order city` | a group of the list's items, each set of values once ([below](#distinct)) | | `SELECT city FROM users GROUP BY city` | `get users select city group city` | a group with no aggregate answers its keys | | `SELECT city, count(*) AS n FROM users GROUP BY 1 ORDER BY 2 DESC` | `get users select city, count(*) as n group city order n desc` | a key by its place in the list | | `SELECT city c, count(*) n FROM users GROUP BY city` | `get users select city as c, count(*) as n group city` | a name without `AS`, where the list goes on or ends at `FROM` | | `SELECT count(*) FILTER (WHERE age IS NULL) AS unknown, sum(total) FILTER (WHERE paid) AS paid FROM t` | `get t select count(case when age is null then 1 end) as unknown, sum(case when paid then total end) as paid` | what `case` leaves of each row: every aggregate passes a null over | | `SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.total > 100)` | `get users where id in (get orders select user_id where total > 100)` | the one equality with the row around it is the list, the rest the query's | | `SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)` | `get users where not (id in (get orders select user_id))` | a row whose key is null has no match: kept, as SQL keeps it | | `SELECT * FROM t WHERE at = (SELECT max(at) FROM t)` | `get t where at in (get t select max(at))` | a query's one value: an aggregate, or `LIMIT 1` | | `UPDATE jobs SET owner = $1 WHERE id = (SELECT id FROM jobs ORDER BY run_at LIMIT 1)` | `set jobs {owner: $1} where id in (get jobs select id order run_at limit 1)` | | | `CREATE TABLE users (id serial PRIMARY KEY, name text NOT NULL, email varchar(200) UNIQUE, age integer, tags text[], meta jsonb)` | `create collection users (name text required, email text @unique, age int, tags [text], meta json)` | the row's `id` is the database's; types as [`fenec import`](https://fenecdb.com/docs/import) maps them ([below](#create-table)) | | `CREATE TABLE lines (order_id int, n int, sku text, PRIMARY KEY (order_id, n))` | `create collection lines (order_id int required, n int required, sku text, @unique(order_id, n))` | a primary key is its rules: unique, and present | | `CREATE INDEX users_age ON users (age)` | `create index on users (age) @sorted` | a btree is `@sorted`; the index's name is not kept | | `CREATE UNIQUE INDEX ON users (email)` | `create index on users (email) @unique` | | | `CREATE INDEX ON orders USING hash (user_id)` | `create index on orders (user_id) @hash` | | | `CREATE INDEX ON posts (owner, created DESC)` | `create index on posts (owner, created desc) @sorted` | over several columns, an index over several fields | | `CREATE INDEX ON docs USING hnsw (emb vector_cosine_ops) WITH (m = 16)` | `create index on docs (emb) @hnsw(cosine, m=16)` | pgvector's operator class names the distance | | `CREATE INDEX ON docs USING gin (to_tsvector('english', body))` | `create index on docs (body) @text` | full text, for [`match`](https://fenecdb.com/docs/fenecql-search#the-text-clause) | ## Joins A `JOIN` on a key is a [`lookup`](https://fenecdb.com/docs/fenecql-lookup#the-lookup-clause) from each row of the first table to its matches in the next, answered as SQL answers it: flat rows, a row a pair — and, for a `LEFT JOIN`, a row of `null`s where nothing matched — the columns the list names under SQL's names. Where only one table's columns are answered and each of its rows is answered once — the other table found by its `id`, or `DISTINCT` — it is that table's `get`, the other's rows a filter. Each line below is the FenecQL it parses to; a `lookup`'s line answers flat, which no FenecQL text asks for. | SQL | FenecQL | Notes | | `SELECT o.id, u.name FROM orders o JOIN users u ON u.id = o.user_id WHERE o.total > 100 ORDER BY o.placed DESC LIMIT 20` | `get orders select id where total > 100 order placed desc limit 20 lookup users on id = user_id required select name` | each order's user; `required` keeps an order only where a user matched | | `SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id AND o.status = 'paid' ORDER BY u.name` | `get users select name order name lookup orders on user_id select total where status = "paid"` | a user with no paid order is a row, `total` null | | `SELECT l.qty, o.status, u.name FROM lines l JOIN orders o ON o.id = l.order_id JOIN users u ON u.id = o.user_id` | `get lines select qty lookup orders on id = order_id required select status lookup users on id = user_id required select name` | a chain: each join hangs off the table before it | | `SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id WHERE u.email = $1` | `get orders select id where user_id in (get users select id where email = $1)` | the users only a filter: each order found once | | `SELECT DISTINCT u.name FROM users u JOIN orders o ON o.user_id = u.id WHERE o.status = 'open'` | `get users select name where id in (get orders select user_id where status = "open") group name` | | | `SELECT u.id, count(*) AS n FROM users u JOIN orders o ON o.user_id = u.id WHERE u.city = 'Leeds' GROUP BY u.id` | `get orders select user_id as id, count(*) as n where user_id in (get users select id where city = "Leeds") group user_id` | the orders' rows, the users a filter: `u.id` is each order's `user_id` | | `SELECT sum(o.total) AS t FROM orders o, users u WHERE u.id = o.user_id AND u.city = 'Leeds'` | `get orders select sum(total) as t where user_id in (get users select id where city = "Leeds")` | a comma is a join, its key in the `WHERE` | | `SELECT u.email FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.id IS NULL` | `get users select email where not (id in (get orders select user_id))` | the rows a `LEFT JOIN` finds none for | | `SELECT id, body FROM docs WHERE kind = 'note' ORDER BY emb <=> $1 LIMIT 10` | `get docs select id, body where kind = "note" near emb $1 limit 10` | pgvector's distance is `near` where the index measures it ([below](#pgvector)) | What a join is read as, and what it cannot be: - **The key.** `ON` pairs a column of the joined table with one of the table joined just before it — several, joined by `AND`, are a key over several fields. Where the answer is the joined rows beside each row — read as a `lookup` — the joined table is read by its column, which must be its `id`, a field with `@hash` or the first fields of an index over several, as a `lookup`'s child key must be; anything else is refused naming the index to declare. Where only one table's columns are answered and the other is a filter, `key in (get …)`, no index is needed, and a small joined table is answered through the first table's index — that list is held to 100 000 values, as any `in (get …)` is. A join that finds the joined table by its `id` looks each row's match up by that id past them, at any size, whichever table comes first and with the joined table filtered, where the other key is an `id`, an int, a float or a timestamp; an aggregate, `DISTINCT`, `EXISTS` or `LEFT JOIN … IS NULL` over a larger joined table is refused, never cut. Its other conditions read one table's columns. A comma between tables is a join whose key is in the `WHERE`, and `USING (k)` pairs `k` of each. - **The names.** A column is named by its table's name or alias, `o.total`; a name with no table is the first table's — the parser reads no schema, and SQL would have refused one two tables hold — and one the first table has no field of is refused, saying to name it with its table. A column no table has is refused, as SQL refuses it, wherever the statement names it — an `ON` term too — and so is a table no database holds. A column answers under its name, `total`, or its `AS`; two that would answer under one name — every table's `id` under `SELECT *` — are refused, since a row holds a name once: `SELECT o.*, u.name AS who`. - **The order and the page** are the first table's, whose rows the others are found for: `ORDER BY` names its columns, and `LIMIT` counts the flat rows, the first table read as far as the page could need. Ordering by a joined table's column would put each row's matches in order rather than the rows: refused, saying to name that table first. - **Aggregates and `DISTINCT`** read one table's columns, the other's rows a filter, where each row of it is counted once: folded over pairs, a row matching three times would be counted three times. Grouping one table's rows with another's column beside each group is a `lookup` after the groups: `get orders select user_id, count(*) as n group user_id lookup users on id = user_id select name`. - **A `LEFT JOIN`'s table in the `WHERE`** reads the rows it kept with nulls: `o.id IS NULL` is the rows with none, and anything else is refused — a condition on its rows goes in `ON`, and one that drops the rows kept with nulls makes it an inner join, written `JOIN`. A condition in a `LEFT JOIN`'s `ON` on a table before it keeps that table's rows and drops only their matches, which no `lookup` says: refused. An inner join after a `LEFT JOIN`, a `RIGHT`, `FULL` or `CROSS JOIN` and a condition between two tables' columns other than the key are refused. ## Refused, and what to write Each is refused with the FenecQL in its error. | SQL | FenecQL | Why | | `ALTER TABLE t ADD COLUMN a int` | `alter collection t add field a int` | | | `DROP TABLE t` | `drop collection t` | | | `TRUNCATE t` | `del t` | | | `BEGIN` | a `/batch` | no transaction is held open across requests; a [`/batch`](https://fenecdb.com/docs/http#batches) lands whole or not at all, and [`require`](https://fenecdb.com/docs/fenecql-writing#require) makes a count its condition | | `CREATE TABLE t (n int DEFAULT 0, u int REFERENCES users(id))` | `create collection t (n int, u int @hash)` | no default, foreign key or `CHECK`: dropped, the collection would take writes SQL refuses ([below](#create-table)) | | `CREATE TABLE t (price numeric(10, 2))` | `create collection t (price_cents int)` | no decimal: an `int` of the smallest unit keeps it exact | | `CREATE TABLE t (id uuid PRIMARY KEY, n int)` | `create collection t (uuid text required @unique, n int)` | a row's `id` is an int the database makes | | `CREATE INDEX ON docs USING ivfflat (emb vector_l2_ops)` | `create index on docs (emb) @hnsw(l2)` | the vector index is a graph | | `SELECT o.id FROM orders o JOIN users u ON u.id = o.user_id ORDER BY u.name` | `get users order name lookup orders on user_id required select id` | a join's rows are put in order by the first table's columns: name the table it orders by first | | `SELECT u.name, count(*) FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.name` | `get orders select user_id, count(*) as n group user_id lookup users on id = user_id select name` | an aggregate over a join reads one table's columns; the groups, then each one's user | | `SELECT * FROM users u RIGHT JOIN orders o ON o.user_id = u.id` | `select * from orders o left join users u on u.id = o.user_id` | a `RIGHT JOIN` is a `LEFT JOIN` the other way round; `FULL` and `CROSS` are none | | `SELECT u.name FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE o.total IS NULL` | `select u.name from users u left join orders o on o.user_id = u.id and o.total is null` | the `WHERE` would read the rows kept with nulls; in `ON` it filters the orders | | `SELECT * FROM t WHERE x = (SELECT y FROM u)` | `get t where x in (get u select y)` | the query may answer several rows, where SQL's `=` refuses a second: `IN`, or `LIMIT 1` | | `SELECT (SELECT name FROM products p WHERE p.id = l.product_id) AS name FROM lines l` | `select p.name from lines l join products p on p.id = l.product_id` | another table's row beside each row is a join | | `SELECT * FROM t WHERE meta->>'source' = 'web'` | `get t where meta.source = 'web'` | a json field's member is a path; `json_extract(meta, '$.source')` is answered so too | | `SELECT id, emb <-> $1 AS d FROM docs ORDER BY emb <-> $1 LIMIT 5` | `get docs select id near emb $1 limit 5` | each row's `_score` holds what the distance is turned into: the L2 distance, the cosine similarity, the inner product ([below](#pgvector)) | | `SELECT * FROM docs WHERE to_tsvector(body) @@ to_tsquery('rust')` | `get docs match body "rust"` | full text is [`match`](https://fenecdb.com/docs/fenecql-search#the-text-clause) over an `@text` field; MySQL's `MATCH … AGAINST` too | | `SELECT * FROM users WHERE email ~ '@example\.org$'` | `get users where email like '%@example.org'` | `~` is contains, not a regular expression ([below](#tilde)) | | `SELECT * FROM t ORDER BY x NULLS LAST` | `get t order x is null, x` | a null comes first ascending | | `INSERT INTO t VALUES (1, 'x')` | `insert into t (a, b) values (1, 'x')` | the fields are named ([below](#insert)) | | `INSERT INTO t (a) VALUES (1), (2, 3)` | `insert into t (a) values (1), (2)` | a row of as many values as fields | | `INSERT INTO t (a) SELECT a FROM u` | `get u select a`, then `insert t $1` | an insert writes documents, not a query's rows | | `INSERT INTO t (k) VALUES ('a') ON CONFLICT ON CONSTRAINT t_k DO NOTHING` | `insert into t (k) values ('a') on conflict (k) do nothing` | the target is the unique field ([below](#upsert)) | | `INSERT INTO t (k, n) VALUES ('a', 1) ON CONFLICT DO UPDATE SET n = 1 WHERE t.n < 3` | `insert t {k: "a", n: 1} if absent else set {n: case when n < 3 then 1 else n end}` | the upsert sets the row it finds, with no `WHERE` | | `SELECT * FROM t WHERE "name" = 'Ada'` | `get t where name = 'Ada'` | `"name"` is text here ([below](#quotes)) | Not read, with no error of its own: `WITH`, window functions, `CAST` and `::`, `LIMIT x, y`, `UNION`, `INSERT OR IGNORE` and `REPLACE`, MySQL's `ON DUPLICATE KEY`, `?` placeholders (`$1` is a parameter). ## Where the meaning is not SQL's ### Nulls: two values, not three SQL's logic has three values: a comparison with a null is unknown, and `WHERE` keeps only what is true. FenecQL's has two. A null is equal to a null and to nothing else, so `=` and `!=` take it as a value — they are SQL's `IS NOT DISTINCT FROM` and `IS DISTINCT FROM` — while `<`, `<=`, `>`, `>=`, `LIKE` and `BETWEEN` are false with a null on either side, and `NOT` makes a false true. Where SQL's unknown leaves a row out, FenecQL may take it in: | Where `x` is null | SQL | FenecQL | | `x = NULL` | never true | true: it is `x IS NULL` | | `x <> 1`, `NOT (x > 1)` | left out | taken in | | `x NOT IN (1, 2)`, `x NOT LIKE 'a%'`, `x NOT BETWEEN 1 AND 3` | left out | taken in | | `x IN (1, NULL)` | left out | taken in: the list holds a null | | `2 NOT IN (1, NULL)` | never true | true | | `x NOT IN (SELECT y …)`, where some `y` is null | never true | the subquery's nulls are left out of its list | Add `AND x IS NOT NULL` to leave out what SQL leaves out. Arithmetic and `||` with a null are null, as in SQL; aggregates skip nulls, as in SQL — `count(x)` counts the rows where `x` is not null — and `sum` over none is null. A null sorts first ascending and last descending, as in SQLite and MySQL — PostgreSQL's default is the other way round — so `NULLS FIRST` is read after `ASC` and `NULLS LAST` after `DESC`, and either other way is refused. `x LIKE 'a%'` in a select list is null for a null. **`<>` is `!=`, null and all.** One could read `<>` as SQL's and keep `!=` FenecQL's, but not exactly: SQL's unknown runs through every `NOT` above it, which no rewrite of the one comparison carries, so the two would part somewhere else instead. Two operators that differ on null alone would be the subtler trap, and FenecQL read `<>` as `!=` before it read any other SQL, so a text that used it would change its answer. One meaning, then, written here. ### Double quotes are text In SQL `"name"` is a column; in FenecQL it is text, as `'name'` is. Where only a name can stand — a collection, the fields of an `INSERT`, a field `SET` assigns, `RETURNING`, an index's fields, a facet — `"name"` is the name, as in SQL. Where a value can stand it is text, as it always was: `where title = "Rust"`. Where SQL would read a column and text would make the answer the same for every row, it is refused rather than answer all the rows or none: - alone on the left of a test against values: `"name" = 'Ada'`, `"age" > $1`, `"x" IS NULL`, `"x" IN (1, 2)`, `"x" LIKE 'a%'`, `"x" BETWEEN 1 AND 2`; - on the right of a test against a literal: `'Ada' = "name"`; - an item of a select list, a key of `ORDER BY` or `GROUP BY`, an aggregate's argument: `SELECT "name"`, `max("at")`. A parameter tested against double-quoted text stays a test: a policy's `$jwt.role = "admin"`. Text on the right of a field stays text: `UPDATE t SET a = "b"` sets `a` to the text `b`, where SQL would copy the column `b`. ### `~` is contains In PostgreSQL `~` matches a regular expression; in FenecQL it finds the text as written, case-insensitively: `email ~ "@example.org"`. Written as a pattern, it would run and match nothing — and an agent that ran `email ~ '@example\.org$'` told its user there were no such rows. So a text that reads as a regular expression — anchored, `^a` or `a$`, or holding `.*` or `.+` — is refused, pointing at `LIKE`: `like 'abc%'` for a start, `like '%abc'` for an end, `like 'a%b'` for a gap. A parameter is taken as the text it is. ### `LIKE` and `ILIKE` `%` stands for any run of characters and `_` for one character — of however many bytes. There is no escape character unless `ESCAPE` names one, as in standard SQL and SQLite; PostgreSQL's default is `\`, and a FenecQL string reads `\` as an escape of its own, so `'50\%'` is the text `50%`: write `LIKE '50!%' ESCAPE '!'`. A pattern that ends in its escape is refused. A `nocase` field compares by its case fold wherever it is compared, `LIKE` too, as PostgreSQL's `citext` does. `ILIKE` compares the case folds — Unicode's full fold, the one [`nocase`](https://fenecdb.com/docs/fenecql-schema#nocase) uses — so `'Straße' ILIKE 'STRASSE'`, and `_` stands for one character of the fold. Its escape takes no character with a case. A collated field's `LIKE` compares its bytes, as its `=` does. `~`, FenecQL's own, is a case-insensitive contains. `LIKE` reads every row the rest of the filter leaves: no index answers it. A prefix over a `@sorted` field walks its index written as a range, `name >= 'abc' and name < 'abd'`. ### An insert names its fields `INSERT INTO t VALUES (…)` without its fields is refused. The parser reads no schema, and a collection's fields have no order a statement could lean on: `id` is the database's and no field's, and a field added goes last. Each row of `VALUES` has as many values as there are fields named. An `id` among them names the row's id; one that is taken is refused, as a key is, unless `ON CONFLICT` says otherwise. ### `ON CONFLICT` FenecQL's upsert is held by any unique key — the `id`, or a `@unique` value, a field's or several fields' — and `ON CONFLICT` is read with no target, as SQLite reads it: `DO NOTHING` passes a row over where any of them is held, and `DO UPDATE SET` sets the row holding its `id`, or else the first `@unique` value it holds, in the order the fields are declared. A target, `ON CONFLICT (email)`, is taken where it names the one unique key the collection's rows are found by — its `@unique` field, the fields of a unique index over several, or `id` where there is no other — and refused, naming the keys, where the collection has another or a row names an `id` beside it: the target and the upsert, held by any, would part there. In `DO UPDATE SET`, `excluded.f` is the row offered (`new.f`) and `t.f`, or `f`, the row held; a `WHERE` is refused. ### Text, `||`, numbers - **`||` is `+`.** It joins two texts, and a text and a number are refused rather than made text: `'n' || 1` is an error where PostgreSQL gives `n1`. MySQL's `||`, an `OR`, is not read. - **Text compares by its bytes**, as `COLLATE "C"` does. A field declared `collate tr` or `und` orders by that collation, and its `=` and `LIKE` compare bytes — see [Collation](https://fenecdb.com/docs/fenecql-schema#collation). - **Division between integers is whole**, toward zero: `7 / 2` is `3`, as in PostgreSQL and SQLite (MySQL's is `3.5`); an integer and a float make a float. - **Every row has an `id`**, an int the database makes and no field declares; `/_schema?as=fenecql` and the MCP `schema` tool say so in a comment. `rowid` and `_id` are refused naming it. - **Errors name what to write.** `contains(…)`, `starts_with(…)` and `ends_with(…)` are answered with `LIKE`; `users.email` — a column named with its table — with the field written bare. ### `DISTINCT`, `EXISTS` and a query's one value `SELECT DISTINCT a, b` is a group of its items, which answers each set of their values once, as does a `GROUP BY` with no aggregate; `ORDER BY` then names the list's columns, as SQL requires of `DISTINCT`. Over an aggregating list whose keys it lists it changes nothing, and `DISTINCT ON` is refused: `first(name by signup)` is the row of each key it asks for. `ORDER BY 2` and `GROUP BY 1` name the list's items by their places — written with `BY`, as SQL writes them: in FenecQL's `order 1` and `group 1` the number is a key, as it always was. Over `*`, whose places the parser reading no schema cannot count, a number is refused, saying to name the columns; one naming an aggregate as a group's key is refused as SQL refuses it. `EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND …)` takes the one equality between a column of its query's table and one of the row around it as the list an `in (get …)` holds, and the rest of its `WHERE` as that query's: `u.id in (get orders select user_id where …)`. A row whose key is null has no match — so `NOT EXISTS` keeps it, as in SQL — and a name with no table is the query's own, as SQL reads it first. Correlated any other way, it is refused. `= (SELECT …)` is the same list where its query answers one row — an aggregate with no group, or `LIMIT 1` — and refused where it may answer several, which SQL would refuse at the second; where it answers none, SQL's comparison is unknown and FenecQL's `in` false, the same under `NOT` as [`NOT IN`](#nulls). ### `CREATE TABLE` Each column's type is the one [`fenec import`](https://fenecdb.com/docs/import) gives a column of it — one table for both, PostgreSQL's names and then SQLite's affinity rules for any other word: | SQL | FenecQL | | `smallint` `integer` `bigint` `serial`, SQLite's `INT…` | `int` | | `real` `double precision` `float` | `float` | | `text` `varchar(n)` `char(n)` `uuid` | `text` | | `citext` | `text nocase` | | `boolean` | `bool` | | `timestamp` `timestamptz` `date` `datetime` | `timestamp` | | `bytea` `blob` | `bytes` | | `json` `jsonb` | `json` | | `text[]` `integer[]` | `[text]` `[int]` | | `vector(n)` `halfvec(n)` `sparsevec(n)` | `vector` `vector` `sparse` | | `point` | `geo` | | `numeric` `decimal` `money`, `interval` `time` `inet` | refused: no counterpart | - **The row's `id`** is an int the database makes: a column `id` of an integer type that is the primary key — `serial`, `generated … as identity`, `AUTOINCREMENT` — is it, and is no field. An `id` of another type, or that is no primary key, is refused. - **A primary key on other columns** is its two rules, `@unique` and `required`, over several columns an index over them — beside the row's `id`, which every row still has. `ON CONFLICT (k)` finds a row by it. - **`NOT NULL` is `required`**: a write must give the field a value, and a `null` is none. `UNIQUE` is `@unique`, `COLLATE NOCASE` `nocase`, `COLLATE "C"` the bytes' order. - **A type's own rules are not held**, as SQLite holds none: `varchar(80)` takes a longer text, and `char(n)` is not padded with spaces; a `uuid` is text as written, its case kept, where PostgreSQL writes it lowercase; a `date` is a timestamp, which keeps a time written with it; `smallint` and `integer` take any 64-bit int, past their 16 and 32 bits; `real` is a 64-bit float, more precise than its 32 bits. - **What fenecdb has no rule of is refused** rather than dropped, which would take writes SQL refuses: `DEFAULT`, `REFERENCES` and `FOREIGN KEY`, `CHECK`, a computed column, a sequence on another column. A key a join reads is a field with `@hash` — `CREATE INDEX … USING hash`. - **`CREATE INDEX`** keeps no name. A btree is `@sorted` (`UNIQUE`, `@unique`), over several columns an index over them; `USING hash` `@hash`; `USING hnsw` `@hnsw` by its operator class — `vector_l2_ops`, pgvector's default, `vector_cosine_ops`, `vector_ip_ops` — with `WITH (m, ef_construction)`, held to pgvector's bounds; `USING gin (to_tsvector(…, body))` `@text`. A partial index, an expression's and `ivfflat`, `gist` or `brin` have no counterpart. ### pgvector's distances `ORDER BY emb <-> $1 LIMIT 10` is `near emb $1 limit 10`, nearest first, where the field's index measures the operator's distance: `<->` L2 over `@hnsw(l2)`, `<=>` cosine over `@hnsw(cosine)`, `<#>` the negative inner product over `@hnsw(dot)` or a sparse field's `@inverted`. Another index's order would be another, and is refused naming the operator to write. The vector is a parameter, a list, or pgvector's text `'[1, 2, 3]'`. The search walks the graph, as pgvector's HNSW index scan does: the nearest are found approximately, and `near … exact` measures every row. Each row's distance is in its `_score`, turned to a similarity: for `<->` it is the L2 distance itself, for `<=>` 1 minus the cosine distance — the cosine similarity — and for `<#>` the inner product, minus pgvector's value. The operator in the list or the `WHERE`, `DESC` and a second key are refused: `near` ranks the rows, and the distance is the score. --- Source: https://fenecdb.com/docs/http-reference # HTTP reference Where to look a thing up: every route, the filters of the query string, the headers that hold a write or mark a read, the statuses a refusal answers with, subscriptions, polling and the change stream. A first request is on [HTTP endpoint](https://fenecdb.com/docs/http); tokens and policies are on [HTTP security and scoped access](https://fenecdb.com/docs/http-access). ## Routes ```http GET / version and collection list GET /collections schemas GET /? rows GET /?&count number of matching rows GET /?select=status,count(*),sum(total)&group=status&order=sum(total).desc aggregates, whole or per group GET /?&facet=brand%20top%205,color rows, and each value's count beside them POST / body: {...} or [{...}] -- an id that is taken: 409 POST /?returning=id,title the rows made, ids and all (* every field), not their count PATCH /? body: {...} DELETE /? PATCH //all no filter — deliberately explicit DELETE //all POST //near body: {"vector":[...], "limit":10} GET //changes?since=N subscription (SSE): shape plus incremental diff POST /query body: {"query":"","params":[...]} POST /batch one query body per line (NDJSON) GET /_metrics counters and gauges, Prometheus's text format GET /_changes?since=N every write on disk after N, a JSON line each (--cdc) GET /_changes?consumer=C from where consumer C committed POST /_changes/consumers/C make C (201), at {"since": N} or the last write; one already there stays put (200; a since ahead of it: 409) PATCH /_changes/consumers/C body: {"since": N} -- move C: commit what it read, or go back GET /_changes/consumers each consumer, where it is and how far behind DELETE /_changes/consumers/C GET /_whoami who the server takes the token for, and the rules that apply to it GET /_schema the schema as a description (Schema in code); ?as=fenecql: as its statements POST /_schema/plan body: a description -- what an apply would do; ?mode=follow: what the server lacks POST /_schema/apply body: a description -- migrations, then what only adds; the server's token POST /mcp MCP for agents (--mcp): one JSON-RPC message a request ``` The _replication, _schema and _metrics routes With `--replication-token`, `/_replication` is taken as well: the stream a replica is fed, its status, and a replica's promotion — see [Replication](https://fenecdb.com/docs/replication). It asks for that token rather than `--http-token`, and a collection named `_replication` is then out of HTTP's reach. `/_schema` compares a schema declared in code with the server's, in the engine every SDK's check goes through — see Schema in code. Its body is the description, `{"format": 1, "fenecql": "create collection ...", "migrations": [...]}` or its collections as JSON. A plan answers `{"applied", "ran", "migrations", "statements", "refusals"}`, and writes nothing; with `?mode=follow` it says only what the code declares and the server lacks, which a scoped token may ask of the collections it reads. An apply takes the server's token, runs the migrations not yet recorded and the additions under the write lock as one block, and answers 409 with the refusals when anything is refused, having written nothing. With `--mcp`, `/_mcp` is the Model Context Protocol's endpoint: an agent's `schema`, `search`, `query` and `get` tools, and `write` and a memory where asked for, each a statement held to the request's token as `/query` is — see [Agents and MCP](https://fenecdb.com/docs/mcp). Without the flag it answers 404. `/_metrics` is the server's statements, latency and data in Prometheus's text format, readable with `--http-token` or the admin token — see [Monitoring](https://fenecdb.com/docs/monitoring). The underscore keeps it out of the way of a collection called `metrics`; a collection called `_metrics` cannot be read over `GET`. ## Filters Filter in the query string as `field=operator.value`, for example `?year=gte.2024`. For anything the pattern cannot say, use `?where=` with FenecQL. | **Operators** | `eq` `neq` `lt` `lte` `gt` `gte` `like` `has` `in` `is` | | **Negation** | the `not.` prefix — `?summary=not.is.null`, `?year=not.eq.1999` | | **Shorthand** | an unrecognised prefix makes the whole value an equality: `?year=2024` | | **List** | `?year=in.(1999,2023)` | | **Paths** | a path into a `json` field where a field goes: `?meta.lang=tr`, `?meta.source.rank=gte.2`, `?select=title,meta.source.site`, `?order=meta.source.rank.desc`; the value is read as JSON reads it — a number, `true`, `null`, a quoted string — and as the text it is otherwise. A body's object is a `json` field's value, every number in it as written, and `PATCH` with `{"meta.lang": "en"}` sets one key inside the field ([Objects and paths](https://fenecdb.com/docs/fenecql-schema#json)) | | **Clauses** | `select` `order` `limit` `offset` `count` `where` `lookup` | | **Ordering** | `?order=year.desc,title.asc`; a collation among the modifiers puts text in its order — `?order=name.und.desc` is `order name collate und desc`, `name.tr` Turkish ([Collation](https://fenecdb.com/docs/fenecql-schema#collation)) | | **Free expression** | `?where=year >= 2024 and tags has "rust"` — full FenecQL | How a filter value is typed Values are parsed by the field's type: `?year=gte.2024` is an int, `?published=gte.2024-01-01` is a timestamp. An unknown field returns 404. `where=` exists for what the pattern cannot hold — `or` groups and function calls. ## Vector search Send the vector in a `POST` body to `//near`, with an optional filter. Add `"match"` to mix in keyword search. ```bash curl -X POST http://127.0.0.1:8080/articles/near -d '{ "vector": [0.1, 0.2, 0.3], "ef": 128, "limit": 5, "select": ["title"], "where": "year >= 2024" }' # [{"title":"the rust book","_score":0.97}] ``` - **`field`** can be left out when the collection has one vector field. - **Query-string filters** are combined with the body's `where`. Why near is a POST `near` is a `POST` rather than a query parameter because a 768-dimension embedding does not fit in a query string. Squeezing it into the URL — base64, truncation — is unreadable and runs into proxy and server URL ceilings. PostgREST's model does not cover fenecdb's headline feature, so `near` gets its own endpoint, and it takes a filter in the body. If the collection has a single vector field, `field` can be left out. Filters in the query string are `and`ed with the `where` in the body. A vector in an answer, and a `_score`, is its `f32`s written as the shortest decimals that read back as them — `0.1` as it was written, the text pgvector gives — rather than the `0.10000000149011612` of the `f64` each widens to. A reader that takes a number as an `f64` and rounds it to `f32`, as a `Float32Array` does, gets every one back: the one `f32` an `f64` would round away, `7.038531e-26`, is written as its `f64`. A `"match"` makes it hybrid: BM25 over the collection's `@text` field ranks the same request, and the two rankings are fused by reciprocal rank ([fuse](https://fenecdb.com/docs/fenecql-search#hybrid)). `match_field` names the text field when there are several; `match_fuzzy` (`true`, or 0 to 2 edits) and `match_prefix` read its words as `match ... fuzzy` and `prefix` do; `fuse_k` and `candidates` are the rank offset and how many candidates each side ranks. ```bash curl -X POST http://127.0.0.1:8080/articles/near -d '{ "vector": [0.1, 0.2, 0.3], "match": "borrow checker lifetimes", "limit": 10 }' ``` ## Embedding a collection Use `lookup` to nest matching rows of another collection under each row, such as each product's latest reviews. ```bash curl 'http://127.0.0.1:8080/products?limit=20&select=name\ &lookup=reviews&reviews.on=product_id&reviews.stars=gte.4\ &reviews.order=created.desc&reviews.limit=3' # [{"name":"Kahve","reviews":[{...},{...},{...}]}, ...] ``` - **Prefix the child's options** with its name: `reviews.on`, `reviews.limit`. - **Chain levels** with a comma: `lookup=orders,lines`. - **A key over several fields** lists a field of each side: `lines.on=org,order&lines.parent=org,id`, FenecQL's `on (org, order) = (org, id)`. - **A level that folds** aggregates in its `select`, as the query's own does: `orders.select=count(*),sum(total)`, each parent's children folded, and `orders.group=status` a row a group, its `order` naming columns (`orders.order=count(*).desc`). Off a query's groups, `.parent` names a column of its list: `/orders?select=courier,count(*)&group=courier&lookup=couriers&couriers.on=user&couriers.parent=courier`. How lookup parameters are prefixed `lookup=` names the collection once; every key prefixed with it configures the clause — `on` (required, the child's field holding the key), `parent`, `select`, `order`, `limit`, `offset`, `required`, `where`, and any field name as a condition, in the same `op.value` form the parent's filters use. Prefixing is what keeps the two sides apart in a query string, the way the clause's position does in FenecQL. ```bash curl 'http://127.0.0.1:8080/shops?limit=20&select=name\ &lookup=orders,lines&orders.on=shop_id&orders.limit=3\ &lines.on=order_id&lines.limit=5' # [{"name":"Merkez","orders":[{"code":"A","lines":[{...}]}, ...]}, ...] ``` A comma-separated list chains, read left to right, each name binding to the one before it and each level keeping its own prefix. A query string has no position to scope by, so the order is written out; the meaning is the same one FenecQL gets from position, up to the same 8 levels. `lookup` is a reserved key here, so a field named `lookup` cannot be filtered over the REST path — the FenecQL and `fenec-server` paths are unaffected, as with the other reserved names. A query with a `lookup` answers with the children nested under the collection's name — one object per parent, with an empty array where nothing matched, and a chain nests all the way down. ```bash curl -X POST http://127.0.0.1:8080/query \ -H 'content-type: application/json' \ -d '{"query":"get products limit 20 lookup reviews on product_id limit 3"}' # [{"id":1,"name":"Kahve","reviews":[{"id":1,"stars":5,"body":"guzel"}]}, ...] ``` ## Holding a write until it can write Send `Fenec-Wait: ` with a `set` or a `del` on `/query` or `/batch`, and while it would write nothing the server holds it, answering the moment it writes — or at the end of the wait, 30 seconds at most, as it would have answered at once. A job queue's claim waits for a job that way (Waiting for a job). ```bash curl -X POST http://127.0.0.1:8080/query -H 'Fenec-Wait: 30000' \ -d '{"query":"del tasks where run_at <= now() order run_at limit 1 returning *"}' ``` - **What wakes it:** a write to a collection it reads, and the time reaching a row's moment — a field compared with `now()`, a `@ttl`. A filter that reads the time any other way is refused (400), as is a request holding a `put`. - **Wrote nothing** means no row written, or a `require` not met; any other refusal is answered at once. - **With `Fenec-After`** the header is that wait's, as below, and holds no write. ## Marking a read Send `Fenec-Read: replica` with a request that only reads, and a router standing in a region where the tenant has a read replica answers it there rather than from the tenant's home (Read replicas); with `Fenec-After` and `Fenec-History`, as an answer from home named them, it is answered from nothing older than that change of that history ([Reading your own writes](https://fenecdb.com/docs/replication#read-your-writes)), a subscription and a `/_changes` page as much as a read: each waits for it before its head. Every server runs a marked request read-only on every route — home, a replica, a single file; a statement, REST, `/batch`, an MCP tool, a change-stream consumer's commit — so a write sent with the mark is refused with `403` and words that say why, never landed; a value but `replica` is a `400`. A copy that has not caught up with its primary yet answers it `503`. ```bash curl -X POST https://ap.example.com/t/acme/query -H 'Fenec-Read: replica' \ -H 'Fenec-After: 1043' -H 'Fenec-History: 2-6c1f0a9e44d7b2c8' \ -d '{"query":"get orders where customer = $1","params":["c-7"]}' ``` ## The data ceiling With `--max-memory`, writes that would grow the data past the limit are refused with `507`. Reads, deletes and `compact` still work. What --max-memory refuses and allows Inside `fenec-server` started with `--max-memory`, the listener holds its writes to that ceiling: above it, a statement that grows the data — a put, a patch, a `create index` — is refused with `507`, a batch stops there as at an error, and reads, deletes and `compact` go on, since they are the way out. The rule and why it is there are under [Server](https://fenecdb.com/docs/server#limits). ## Refusals and their statuses A refusal is a status and `{"error": "..."}`. The status says what to do about it; a `/batch` that stopped adds where (`at`) and what stayed applied (`completed`), a tenant's quota names itself (`quota`, and the `Fenec-Quota` header), and one to come back from says when (`retry_after`, the seconds of its `Retry-After` header: a page on another origin, or an app's replica, reads the body; a quota's 429 says it to the millisecond as well, `retry_after_ms`, which is the one to wait). A write whose outcome the server cannot know says so (`"outcome": "unknown"`, a 500), with where it left the database in the body (`seq`, `history`) rather than in `Fenec-Seq`, which a client takes as its own position. Every SDK's error carries the rest; the outcome and its position are the Go, .NET, JavaScript and Python clients' (Go's and .NET's code `outcome_unknown`, JavaScript's and Python's `outcome`). | Status | Means | What to do | | 400 | a statement that does not parse or run, a bad parameter | fix the request | | 401 | no token, or a wrong one | send the right token | | 403 | outside the token's rules — a row, a field, a change no transition names, a `returning` row it may not read, the write put back whole — a write to a replica or a read-only server, or one marked `Fenec-Read` | write where it is taken | | 404 | a collection, field or tenant that is not there | | | 409 | an id or a `@unique` value taken, a collection that exists | a lost race for a value | | 410 | a `/_changes` cursor the server no longer reaches | start again from the `since` it names | | 412 | a `require` not met, its batch put back | read again, decide again | | 413 | a body past the ceiling, 64 MB | send less at once | | 422 | an `Idempotency-Key` sent with another request | a new key for a new request | | 429 | a tenant's quota of `streams`, `held`, `requests`, `writes` or `write_bytes` | come back after `retry_after_ms` | | 500 | outcome unknown (`"outcome": "unknown"`): a write a tenant's node landed and answered after its lease from the router lapsed — its tenant may have been failed over without it, or still be there with it; a `/batch` that ran a statement at a time says how far it got, its `seq` the last landed statement's | send it again with the same `Idempotency-Key`; without one, wait a lease and a tenth (`--auto-failover` × 1.1) after the answer — until the tenant settles the node answers reads with the write in them, one sent with `Fenec-After` too — then read it back, or send a read with `Fenec-After` and `Fenec-History` set to its `seq` and `history`, which a primary without it answers 504 (for a batch, a 504 does not say which earlier statements survived: read those back) | | 500 | an fsync that failed under `--sync always`: the write may or may not be on the disk, and the server takes no write until its file is opened again | restart the server, then read the write back, or send it again with the same `Idempotency-Key` | | 503 | too many connections or subscriptions; a connection the server could make no thread for, at its thread limit; a tenant being moved or failed over; a node going down. One written as the connection was taken, before the request was read, says so in `Fenec-Unserved` (`ceiling` or `no_thread`): the request never ran | come back after `Retry-After` seconds | | 504 | a `Fenec-After` change, sent with a read, a subscription or a `/_changes` page, that did not reach a replica in the wait, or one a primary's history does not hold — lost in a failover; `seq` and `history` say where it stands | ask again, or ask the primary; take the primary's position as yours | | 507 | past `--max-memory`, or a tenant's quota of `bytes` or `rows`; the write put back whole | delete, then write again: waiting does not help | ```bash HTTP/1.1 429 Too Many Requests Fenec-Quota: writes Retry-After: 1 {"error":"the tenant writes past its quota of 100 rows a second","quota":"writes","retry_after":1,"retry_after_ms":10} HTTP/1.1 503 Service Unavailable Retry-After: 1 {"error":"the tenant is being moved; retry shortly","retry_after":1} HTTP/1.1 500 Internal Server Error {"error":"the write landed on this node, but by the time it was answered ...","outcome":"unknown","seq":4182,"history":"2-6d1f0c2a9b3e4471"} ``` A quota is a tenant node's (`fenec-server --dir`): what each bounds is under [Sharding: reference](https://fenecdb.com/docs/sharding-reference#quota-bounds), how it is set under [Sharding: tenants](https://fenecdb.com/docs/sharding-tenants#quotas) and why the statuses are these under [Sharding: design](https://fenecdb.com/docs/sharding-design#quota-design). 507 is the storage the request needs and the tenant has not got — the request is fine, and a smaller one may fit, so not 413, which is about the body; 429 is a rate or a share the tenant comes back within. ## Subscriptions `GET //changes` is a server-sent events stream: first the rows matching your filter, then each change to them as it happens. ```bash curl -N 'http://127.0.0.1:8080/articles/changes?year=gte.2024' # event: seed # data: {"seq":12,"rows":[...]} ``` - **Limited separately** from ordinary requests: `--http-max-streams`, 64 by default, a database's -- on a tenant node each tenant's own -- and each one of `--max-connections` too, which a node's tenants share ([a tenant's share](https://fenecdb.com/docs/sharding-reference#quota-bounds)). - **A row reaching its `@ttl`** is told as a deletion at its time, as a write would be, not when the sweep deletes it a minute and its grace later. - **Sent after a write** (`Fenec-After`, and `Fenec-History` as the write's answer named it), a stream waits for it before its head, as a read does: its seed holds the write, or it is answered `504` with where the server stands (Reading your own writes). One resumed with `since` waits the same way: `since` is where the stream left off, not a write it must see. - **Its head says where the server stood** as it opened: `Fenec-Seq`, the change its seed is at or after, and `Fenec-History`, the history that change and every event's `seq` are of, as a write's answer names it (none on a database that never had a replica). A read sent after an event's `seq` to another copy goes with both (Reading your own writes): a number alone names no write across a failover. `FenecHttp.live` on a client reading near sends its runs so. - **In the browser,** [Sync](https://fenecdb.com/docs/sync) builds on this stream. Why a subscription does not poll `GET //changes` is a server-sent events stream: a seed carrying the current shape, then incremental diffs. No driver needed. A subscription does not poll. `fenec-core` has no waiting primitive at all — there are no threads in wasm — it only reports that the change counter has reached a point, and `fenec-http` ties that to a `Condvar`. A 50 ms polling loop would take 20 read locks per second per subscriber and still add 50 ms of latency. Every subscription is a connection and a thread, so they are counted separately from ordinary requests as well (`--http-max-streams`, default 64, a database's: on a tenant node each tenant's). Sharing one ceiling would let 100 subscribers shut the server to plain requests. A stream is also one of the node's `--max-connections`, the bound every tenant of a node shares, of which each tenant's streams take a quarter at most unless its quota says otherwise. The client-side layer built on this stream is [Sync](https://fenecdb.com/docs/sync). A row past its `@ttl` leaves every read at once and the file at the sweep, a minute and its grace later; a stream of its collection is told of it at its time. It works out when the next row of the collection reaches its time -- a step of the field's ordered index, again after each write to the collection -- sleeps until then if no write comes first, and reads the rows that reached it since it last looked, the shape ANDed in, telling each as a deletion as the sweep's delete would be told: a scoped stream of the rows it was sent alone, a shape that holds nothing (what `FenecHttp.live` opens) that something changed. Heard at the sweep, a courier whose phone went quiet stayed on a dispatcher's map 73 s after it had gone from every read; at its time, it is told about 10 ms after it. The sweep's delete comes after, a deletion of what is gone already. What a stream costs, measured on an M1 with streams of a shape that holds nothing (what `FenecHttp.live` opens): 45 to 57 KB of resident memory and a thread each, and every write wakes every stream, which reads what it changed: a write's p50 went from 0.26 ms with none to 0.94 with 60 and 3.1 with 500. A page thousands view should not hold one a viewer; it polls (below). Under a [scoped token](https://fenecdb.com/docs/http-access#scoped-access), a stream of a shape that holds nothing (`where=false`) is told of the writes to rows the token may read, and of nothing else: the server follows the token's own filter, keeps the ids it selects and sends none, and a write to one of them, or one of them leaving the token's rows, is a `change` holding no rows. ANDed with the token's filter, the shape matched nothing and `FenecHttp.live` never ran again; told of every write, the stream would hand the token the moments of other users' writes. ## Polling a read Send `If-None-Match` with a `/query` read and the answer carries an `ETag`: the change the database stood at. Sent back, the tag is answered `304`, with no body and the query not run, while no write has touched a collection the read reads. ```bash curl -si http://127.0.0.1:8080/query -H 'If-None-Match: "0"' \ -d '{"query":"get inventory select available where sku = $1","params":["TENT-1"]}' # HTTP/1.1 200 OK # ETag: "48211" curl -si http://127.0.0.1:8080/query -H 'If-None-Match: "48211"' -d '...the same...' # HTTP/1.1 304 Not Modified ``` - **Nothing is held between polls:** no stream, no thread, no wake-up at a write. `FenecHttp.live(query, cb, { poll: 5000 })` polls so. - **Untagged**, and run each time: a read of a collection whose rows expire (`@ttl`), one calling `now()`, one holding an inner `get` — answers that move with no write. - **A scoped token's tag is its answer's** (`"c…"`, a digest of the rows): its query runs every time and is answered 304 when the rows are the ones it had. Tagged by the change counter, 304 against 200 -- and a 304's speed -- told it when anyone wrote to what it reads, other users' rows among them, and the tag how many writes the database had taken. How a 304 is decided, and timed Whether a collection was written since a tag is the change ring's answer (`Database::changed_collections_since`): a tag older than the ring reaches, or a collection dropped since, counts as a change and the query runs. A read without the header is answered as before, no tag worked out. Over one keep-alive connection a 304 took 54 us at the median, against 55 for a read by key and 95 for a page of 24 run; the round trip is most of either. A browser keeps its connection between polls, so a public page polls through the site's own server or a proxy, which keeps few connections to fenec-server however many viewers it has. A tag is a change of the copy that answered. A read replica holding that change of its history (`Fenec-After`) judges it as home does, but only up to where it stands: behind home it answers 304 to a write home holds, so `FenecHttp.live` polls home on a client reading near (Read replicas). ## Change data capture To see every write exactly as it happened — to feed a search index, Kafka or a read model — start the server with `--cdc` and read `GET /_changes`: one JSON line per write. ```bash fenec-server --file data.fenec --http 127.0.0.1:8080 --sync 250 --cdc curl -i 'http://127.0.0.1:8080/_changes?since=0&limit=1000&wait=10000' # Content-Type: application/x-ndjson # Fenec-Next: 5 # Fenec-Seq: 5 # # {"seq":1,"at":1790784252138,"collection":"a","op":"create"} # {"seq":2,"at":1790784252158,"collection":"a","op":"put","id":1,"doc":{"id":1,"n":1,"t":"x"}} # {"seq":3,"at":1790784252158,"collection":"a","op":"put","id":2,"doc":{"id":2,"n":2,"t":"y"}} # {"seq":4,"at":1790784252173,"collection":"a","op":"put","id":1,"doc":{"id":1,"n":1,"t":"z"}} # {"seq":5,"at":1790784252187,"collection":"a","op":"del","id":2} ``` - **Send `Fenec-Next` back as the next `since`** and nothing is missed or seen twice. - **Only writes already on disk** are handed out. - **`Fenec-Seq` is the last write the database holds:** when `Fenec-Next` has reached it, you have every write. - **Sent with `Fenec-After`**, a page waits until the server holds that change, as a read does, and its `Fenec-Seq` is never behind it; a write not yet on the server's disk comes on a later page. How /_changes pages through the writes on disk A subscription keeps a query's rows up to date, and a client that falls behind its ring of ids is sent the rows again. A service that must see every write — to index it elsewhere, send it on to Kafka, or keep a read model — reads `GET /_changes` instead: the writes on disk, documents and all, one JSON object a line, from any write the server still keeps. - `seq` is the change counter's number for the write, and `Fenec-Next` the last one in the answer: sent back as the next `since`, nothing is missed or had twice. Keep it where your consumer keeps its own state, and commit the two together. - `Fenec-Seq` on every answer is the last write the database holds, on disk or not yet, the writes the stream leaves out (a consumer's cursor, an idempotency key) among them. An answer whose `Fenec-Next` is that has every write there is: a worker that catches up before it serves knows it at once, where an empty page alone could not tell it from one whose writes are not on disk yet under `--sync 250`, and waited out pages to be sure. A `Fenec-Next` behind it has writes to come: read again with `wait`. A client that notes `Fenec-Seq` for `Fenec-After` reads a replica from there on. - A batch's writes are one record on disk and come together, numbered one by one; a cursor inside one goes on after the write it names. - Only what an fsync has covered is handed over, as a replica is fed: a write the server lost in a crash never reached a consumer. - `since` left out is the last write on disk: what is written from now on. `limit` is 1 000 unless given, 10 000 at most; `wait` is how long an answer with nothing in it waits for a write first, in milliseconds, 30 000 at most. - The server keeps as many writes as `--replication-buffer` holds (64 MiB unless given), written to disk or not yet. A cursor further back is answered 410 with the first `since` it does serve — which may be a write not on disk yet, whose read waits for it — never with writes missing from the middle; one past the last write — another database's cursor, or this one restored — 409. The feed starts at the change counter when the server starts. - `op` is `put`, `del`, `create`, `alter` or `drop`; a put carries the document as it was written, as JSON writes a row. - It needs `--http-token` or no token at all: a scoped token's filter could not hold back the deletion of a row it never saw, and is refused (403). `--cdc` keeps the feed a primary keeps for its replicas and feeds none; with `--replication-token` it is kept already, and a tenant node's tenants have theirs at `/t//_changes`. It needs `--file` and a `--sync` other than `off`. Keeping it cost nothing measurable: 16 400 single puts a second over HTTP either way, and 9 000 rows of 128 dimensions read back at 283 000 a second. ## Consumers A consumer with no storage of its own can let the server remember its place, like a Kafka consumer group: create it, read from it, then commit how far it got. ```bash curl -X POST http://127.0.0.1:8080/_changes/consumers/search-index curl -i 'http://127.0.0.1:8080/_changes?consumer=search-index&wait=10000' # Fenec-Next: 42 -- index the lines, then: curl -X PATCH http://127.0.0.1:8080/_changes/consumers/search-index -d '{"since": 42}' ``` - **At least once:** a crash between reading and committing means those writes come again. - **Safe to create at every start:** a `POST` creates a consumer that is not there (201) and leaves one that is where it stands (200, with its `since`), so a relay that creates its consumer each time it starts goes on from where it stopped. - **Moved only by a `PATCH`:** forward to commit, or back to read again. A `POST` whose `since` is ahead of an existing consumer is a commit sent the old way, and is refused with 409 naming where the consumer stands. How a stored consumer cursor works A consumer that keeps no state of its own can have the server keep where it is, as a Kafka consumer group keeps its committed offset. Made with a `POST` — at the last write on disk unless the body names a `since` — it reads with `?consumer=` from where it stands, and moves with a `PATCH` of `since` once it has done with what it read, or back to a `since` it wants to read from again. A `POST` to a consumer that is there already changes nothing and answers 200 with where it stands; only a consumer it makes is 201. The `since` in its body is where a new consumer starts, as Kafka's `auto.offset.reset` applies only to a group with no committed offset, so a program can make its consumer with `{"since": 0}` every time it starts and still go on from its last commit. A `since` ahead of where an existing consumer stands is 409, `{"error": …, "since": }`: it is a commit sent as a `POST`, and answered 200 its client would read the same page forever. A program that must know it made the consumer reads the status. A `PATCH` of a consumer that is not there is 404 rather than making it: the `POST` is the one way to make one. Both answer with where the consumer stands afterwards and with `Fenec-Seq`, as a write does, so a read sent after it (`Fenec-After`), to a replica too, finds it there. A commit past nothing but other commits writes nothing and leaves the consumer where it was, which reads the same writes; its answer says so. A `since` past the last write the database holds is 409 for either. So a consumer has each write at least once, and a write again after a crash between reading it and committing. The cursors are rows of a `_consumers` collection, written as any write is — on disk before the answer under `--sync always`, and on the replicas — and their own writes are left out of the stream, the cursor going past them. A replica reads from where the primary's consumer stands, refuses to make, move or delete one (403), and once promoted goes on from the same place. On a tenant node they are the tenant's writes: while it is being moved, or its node's lease is out, each is answered 503 to come back. A read with `wait` waits on past them too, so a consumer's own commit does not wake its next read, and a commit past nothing but cursors writes nothing: a consumer that commits after every answer, an empty one too, sits idle while nothing else is written. An unknown consumer is 404 rather than read from "now" each time, which would miss what is written between two reads. A consumer further behind than the server keeps is 410 with the first `since` it does keep, as a cursor is; a `PATCH` there goes on from it. The server keeps writes by their size, written to disk or not yet, so that `since` can be ahead of the last write on disk: a `PATCH` takes it, and the read from there waits for the disk. `GET /_changes/consumers` lists each consumer's `since` and how many writes it is `behind`. --- Source: https://fenecdb.com/docs/http-access # HTTP security and scoped access Who may ask: a bearer token and CORS, JSON Web Tokens held to a policy file down to the rows, and the keys that verify them. The routes are in the [HTTP reference](https://fenecdb.com/docs/http-reference). ## Security Protect the endpoint with a bearer token, and put a TLS terminator in front of it on an open network. ```bash fenec-server --file data.fenec --http 127.0.0.1:8080 \ --http-cors 'http://localhost:8787' \ --http-token "$TOKEN" \ --http-read-only ``` | `--http-token ` | every request needs `Authorization: Bearer `, compared in constant time | | `--http-cors ` | sets `Access-Control-Allow-Origin`; without it no CORS header is sent at all. A browser’s preflight (`OPTIONS`) from that origin, or any with `*`, is answered 204 before a token is asked for -- the browser sends none with it -- on a single database and under `/t//`. Every answer exposes `Fenec-Seq`, `X-Request-Id` and `Retry-After` and lets `Idempotency-Key` in. Through `fenec-shard`, give the router the flag: it answers the preflight itself and puts its headers on its own answers and in place of the node's ([Tenants and sharding](https://fenecdb.com/docs/sharding-reference#cors)) | | `--http-read-only` | write endpoints return 403; `fenec-server`'s own path is unaffected | | a replica | every write returns 403, whatever the flags: its writes come from its primary | | non-loopback address | refuses to bind without a token, unless `--insecure` is given | TLS, unfiltered writes, body limits and what a shape leaks There is no TLS, the same rule as `fenec-server`: on an open network it needs a TLS terminator in front. A `PATCH` or `DELETE` without a filter is refused, and covering a whole collection needs a separate **path** rather than a flag — a key like `?all=true` would collide with a field named `all`. Limits are shared with `fenec-server` (`--max-connections`, `--idle-timeout`: a connection silent that long between requests is closed with nothing written, and one that went silent part way through a request is answered 408); the body ceiling is 64 MiB and the header block 64 KiB. `Transfer-Encoding: chunked` is unsupported and refused with 411, which beats reading half a body. A field whose name matches a clause name (`select`, `order`, `limit`, `offset`, `count`, `where`, `group`) cannot be filtered over HTTP; the FenecQL and `fenec-server` paths are unaffected. **A shape alone is not a security boundary.** Under the server's own token, which ids changed is visible to a subscriber even when the rows are not. Rows are hidden by a scoped token, below. ## Scoped access Give each user a JSON Web Token, and a policy file decides which collections and which rows that token can read or write. A browser can then talk to the database directly. ```text # collection access rows role notes read,write where owner = $jwt.sub posts read where published = true or author = $jwt.sub posts write where author = $jwt.sub messages read,insert where room in $jwt.rooms journal read,insert where owner = $jwt.sub journal append-only accounts update(balance, held) for app orders update(status, run_at) where customer = $jwt.sub and status = "ready" and new.status = "cancelled" orders read(pickup, run_at) where run_at <= now() for courier positions read where courier in (get orders select courier where customer = $jwt.sub) * read for dashboard ``` - **Reads** only see the rows the rule allows. - **Fields.** `read(pickup, run_at)` shows the rows it admits with those fields alone, and `id`: a row shows the fields of every read rule admitting it, the rest `null`. A courier sees a ready order's pickup, and the customer's door once the order is theirs. - **Writes** must stay inside the rule, or get 403. `write` grants `insert`, `update` and `delete`, and each can be granted alone; `update(balance, held)` grants an update of those fields and no other. - **Transitions.** `new.status` reads a row as the write leaves it, and a bare name as it stands: the `orders` rule above lets a customer cancel a ready order, and not take a cancelled one back. - **`returning`** answers rows the token may read as the write leaves them: a courier who reads the orders they carry claims a ready one and is answered it, claimed. - **`append-only`** collections take inserts from scoped tokens, and refuse every update and delete with 403. - **A list claim**, such as `"rooms": ["r1", "r2"]`, goes after `in`. - **Another collection.** `in (get …)` reads a rule's list from rows: the `positions` rule above lets a customer read where the courier carrying their order is, and nothing of the others. - **The server's own `--http-token`** still has full access. A whole product built on these rules is [examples/saas](https://github.com/fenecdb/fenecdb/tree/main/examples/saas): sign-up and sessions owned by the app, a tenant per organisation behind `fenec-shard`, roles and teams as grants and list claims, live boards and search under each person's token, with its threat model and the attacks its tests try. Every rule of a scoped token `--http-token` is everything or nothing. With `--jwt-secret` the endpoint also takes HS256 JSON Web Tokens, each held to the rules of a `--policy` file, collection by collection and down to the rows, so a browser can talk to the database directly with a token per user: ```bash fenec-server --file data.fenec --http 0.0.0.0:8080 --http-token "$ROOT" \ --jwt-secret-file jwt.secret --policy policy.txt fenec-server --jwt-secret-file jwt.secret --mint-token '{"sub":"alice","exp":1893456000}' ``` - **Reads** get the rule's filter ANDed in at every level: the collection's `where`, each `lookup` below it, a subscription's shape, a `count`. A collection no rule grants a token anything on does not exist for it: 404, and absent from the list. - **Writes** get it twice, as PostgreSQL's `USING` and `WITH CHECK`: a `set` or `del` touches only the rows the filter matches, and every document written — a put, or a row a `set` rewrote — has to match it afterwards, or the write is refused with 403. A put that leaves out a field the filter pins to a claim gets the claim's value, so `{"title":"a"}` is alice's. A scoped put names no `id`, which could overwrite a row the token cannot see, and a scoped token changes no schema and runs no maintenance. - **`returning`** answers what the write wrote, so the rows it answers are held to the read rules: a `set`'s as it wrote them, a `del`'s as they were. A row the read rules leave out by a field the write does not change is passed over, as a `get` passes it over; a `set` that would leave a row the token may not read is refused with 403 and writes nothing. PostgreSQL asks its `SELECT` policies of the row before an `UPDATE … RETURNING` as well as after, which is why a courier who reads the orders they carry, claiming a ready order, would take nothing there and be told nothing; here the terms over the fields the `set` writes are asked of the row it leaves, and the claim answers the order, claimed. Without `returning` a write's rows are those its own rules reach, read rules or not. - **Grants** are `read`, `insert`, `update` and `delete`, joined by commas, and `write` is the three writes. An insert is a put of new documents, held to the filters of the rules granting `insert`; an update is a `set` and a delete a `del`, of rows the token may read and the filters of the rules granting them, a `set`'s rows checked again after. `update(balance, held, status)` names the fields an update may change: a write over a row — a `set`, a REST `PATCH` or `PUT`, a line of a `/batch` — is judged by the fields it changes, and each of them must be one rule's whose filter admits the row before and after, or it is refused with 403 naming a field (a batch holding it is put back whole). A field written with the value it holds is not changed. Rules widen each other, each with its fields and its rows: `notes update where owner = $jwt.sub` beside `notes update(title)` changes every field of the token's notes and the title of anyone's. A token may insert into a collection it cannot read, such as an event stream: its reads of it are refused with 403 and it is not listed. `expired` grants the rows past their `@ttl`, which `expired()` reads ([the reaper](https://fenecdb.com/docs/fenecql-indexes#reaper)): a statement asking it is held to the filters of the rules granting `expired` beside the ones its operation needs, and neither `write` nor `*` grants it — a lapsed hold is the reaper's, not a user's to see again. - **Fields a read shows.** `read(pickup, run_at)` grants the rows its filter admits with those fields and `id`; `read` grants every field. A row shows the fields of every read rule admitting it, and the rest answer `null` — in `/query`, `/batch`, REST, each `lookup` level by its own collection's rules, a write's `returning`, a subscription's seed and changes, and the MCP tools. Beside `orders read where courier = $jwt.sub for courier`, the rule above shows a courier every ready order's pickup and the whole of the orders they carry. A field no read rule grants is not there: `select *` leaves it out, `describe`, `/collections` and `/_schema` do not list it, and naming it is refused (403) as a name the collection lacks is. ```text get orders where dropoff = $1 -- no ready order matches, whatever its door get orders where not (dropoff = $1) -- every ready order does: no door told from another get orders order dropoff -- 403: the order would tell the doors get orders where courier = $1 order dropoff -- $1 their sub: every order reached shows it ``` A field withheld from a row must not show through what a statement does with it either. A `where` term reading it holds only where the row shows it — `dropoff = $1` is asked as `(courier = $jwt.sub and dropoff = $1)`, the `or` of the filters of the rules granting `dropoff` — in a `get`, a `set` or `del`, a `lookup` level, a facet's and an inner `get`'s; `and` stops at the first false, so the term is never worked out over a value it may not see. `order`, `group`, `having`, a `facet`, an aggregate, `match`, `near`, a highlight, a `lookup` key and a `set`'s values read a field only where every row they reach shows it, and are refused with 403 naming it otherwise: when every read rule of the collection grants it, or when one rule granting it has no filter or one whose `and` terms are each a term of the statement's own `where`, parameters bound — as the last line above. A computed column over such a field is `null` where the row does not show it and never worked out there, and an inner `get`'s column of one is listed from the rows showing it. A write reaches the rows it reached before; its `returning` is masked as a read's rows are. PostgreSQL's column privileges are a role's and the same on every row, and refuse a statement naming a column the role lacks, `SELECT *` included; a column shown on some rows is a view of `CASE WHEN … THEN col END` there, whose `WHERE` reads `NULL` on the rest where this reads the term as false. A token whose rules name no fields reads as before; one whose do loses the stored-bytes path of a plain `get` where a column is masked, and pays each rule that could show a row more, per row: over HTTP on loopback a row by id took 28.0 µs against 26.0 under the same two rules naming no fields, a page of twenty 39.5 against 34.4, about 0.13 µs a row of it in process (`make policy-bench`). - **Transitions.** A rule's filter reads the row as it stands, and `new.` reads it as the write leaves it, as `new.` names the document in an upsert's `else set`. One filter asked of the row before and after cannot say which way a row may go: `status = "ready" or status = "cancelled"` lets a customer cancel an order and take a cancelled one back, and `run_at` granted lets them move it to the head of the queue. A rule naming `new.` is a transition, asked of the two rows together: ```text # a customer cancels a ready order, out of the queue for good orders update(status, run_at) where customer = $jwt.sub and courier is null and status = "ready" and new.status = "cancelled" and new.run_at is null for customer # a courier claims an order whose lease is out, for themselves, counting the attempt orders update(status, run_at, courier, claim, attempts) where run_at <= now() and new.status = "assigned" and new.courier = $jwt.sub and new.attempts = attempts + 1 for courier # and delivers only what they carry orders update(status, delivered_at, run_at, claim) where courier = $jwt.sub and (status = "assigned" or status = "picked") and new.status = "delivered" and new.run_at is null for courier ``` A write over a row is the token's when one rule granting `update` names every field it changes (or names none) and admits the change: a transition by being true of the row before and after together, any other rule by admitting each. Rules naming no fields and no `new.` widen each other as before, the row before one rule's and the row after another's, as PostgreSQL's permissive policies do; a transition lends neither half. A `set` picks the rows a transition reaches by what the row before can answer of it — `status = "ready"` of the first rule above — and each row it writes is then asked the whole rule, a refusal 403 and the statement (or the batch) put back. The row after is held to what the rule says of `new.` and nothing more: the field list keeps the rest where it was, so a transition with none should pin what must not move (`new.customer = customer`). A rule naming `new.` grants `update` alone — a read, an insert or a delete has no row after, or none before — and one granting more stops the server at startup. A scoped `set` of one row over HTTP on loopback took 5.0 µs more than the server token's 45 µs under a plain rule, 5.7–5.9 under a field list and 6.1–6.8 under a transition ([`make policy-bench`](https://github.com/fenecdb/fenecdb/tree/main/crates/fenec-http/examples/policy.rs)). - **`append-only`** is a line of its own, `journal append-only`, with no filter and no role. No scoped token updates or deletes there, by any route — `/query`, REST, `/batch` — whatever its rules grant: `write` and `*` mean `insert` there, and a rule naming `update` or `delete` for the collection stops the server at startup. It binds JSON Web Tokens and not the server's own token: corrections, erasure and `@ttl`'s sweep stay with whoever holds that, and an app server that should only append is given a scoped token of its own, a role for instance. - **List claims.** A claim that is a JSON array goes after `in`: `room in $jwt.rooms` matches the rooms it lists, `room in [$jwt.rooms, "lobby"]` those and one more, and a text claim there is a list of one. It holds reads, writes (`WITH CHECK`: a post into a room the list does not hold is 403) and subscriptions alike. A list holding a `null`, a `""`, a list or an object is no claim, and the rule naming it does not apply; `[]` is a list of none. A token whose list claim holds more than 1 000 values is refused (401). A `role` claim may be a list too, and a rule `for` a role applies when the list holds it. Membership kept in another collection is a rule's `in (get …)` (below). - **Rules that read another collection.** A rule's filter takes `in (get …)`, its claims bound inside as anywhere else: ```text # a customer follows the courier carrying their order positions read where courier in (get orders select courier where customer = $jwt.sub and (status = "assigned" or status = "picked")) for customer # a member reads and posts in the rooms they belong to messages read,insert where room in (get members select room where user = $jwt.sub) ``` The inner `get` reads the rows of its collection the token's own `get` would, held to the token's rules there; one no rule lets the token read gives none of its rows, as PostgreSQL's row security gives a policy's subquery none of a table no policy grants. The fields it reads are the policy's own: its list is never shown, only what it admits. It is answered before each statement, under the lock that runs it, as the `in` list it is: reads, a `lookup` level, a facet, a `match` and an agent's MCP tools see the list as it stands, and a write's `WITH CHECK` — an insert's document, an update's row before and after, a transition's pair, an upsert's row written over, `returning`'s rows — is tested by it where no query could run. A rule naming the fields it shows may read another collection too, `positions read(courier, at, loc) where courier in (get orders …)`: each row is shown the fields of the rules admitting it as their lists stand, in a read, a `lookup` level and a stream ([Fields a read shows](#field-grants)). A `/batch` answers it again for each statement: a courier's claim, then a read of the customer's door the claim lets them read, is one round trip. The list is held to a list claim's bound, 1 000 values, and a statement whose list would hold more is refused (400) rather than answered with a list cut short; a value held by many rows counts once. Rules that read each other's collections — `positions` reading `orders` whose rules read `positions`, at any depth, whatever their roles — stop the server at startup, since none of them could be answered first; so do a rule on `*` that reads a collection, a claim anywhere in the inner `get` but its `where`, and what an inner `get` may not say anyway. A scoped subscription whose rules read another collection is bound again when that collection is written: the courier enters a customer's stream of `positions` as the order is claimed and leaves it, a deletion of the row it was sent, as it is delivered, with no write to `positions` between. It costs one inner `get` a statement — a `@hash` bucket's read, 1.8 to 2.1 µs in process and 4.3 to 4.7 µs over HTTP on loopback above a rule naming a claim — and a stream one at each write to the collection its rules read: beside 100 scoped streams a write took 1.89 ms of the server's CPU against 1.65 under a rule naming the courier as a claim, every scoped stream looking at every write either way. A token with no such rule runs as it did (`make policy-bench`, `ARGS=streams`). - **`match` scores over the rows the token may read.** BM25's statistics — how many documents, their mean length, how many hold each word — are taken over the rows the query's filter selects, the token's rules among them, not over the collection: there a user's own memo scored 9.87, then 4.79 once another user wrote 200 private memos holding the same word, which told the first how many of the second's held it. A score is what a collection holding the user's rows alone would give, and so is the order of `match`, `rerank`'s candidates and `fuse`'s text side. It costs the token's query its rows' lengths and each word's count among them: over 100 000 documents, a filter keeping 2.5% took 0.16 ms and takes 0.24, one keeping half 5.4 and 6.2. The server's own token scores over the collection, as before. - **[Subscriptions](https://fenecdb.com/docs/http-reference#subscriptions)** are told only of rows they were sent: a scoped subscriber hears of a row leaving its shape only if the row was in it, so the ids other users write never reach it — rows leaving as a rule that reads another collection moves among them. Where a rule names the fields it shows, a row written again that shows what it showed is not sent: a write to a field the subscriber does not see would tell it when. - **Tokens** are HS256, or RS256 with `--jwt-keys` (below). The algorithm is the key's, not the token's, so `alg: none` is refused. Every token is checked against `exp` and `nbf`, and one with no `exp` is refused (401): it would be good for ever, and a token that leaked could only be outlived by rotating every key. `--jwt-require-exp off` takes such tokens again, and `--jwt-max-age ` also refuses one whose `exp` lies further ahead than that. `--mint-token` gives claims naming no `exp` one an hour away. A rule with `for` applies to tokens whose `role` claim names that role, or lists it; rules on one collection widen each other; a rule whose filter names a claim the token lacks does not apply, and grants nothing. Nor does one naming a claim the token holds as `null`, as `""`, as an object, or as a list where the rule reads one value: `"sub": null` is no subject, and `owner = $jwt.sub` does not become `owner = null`, which every row with no owner matches. A number and a bool are values like any other. A policy names its parameters by claim alone: one writing `$1` or `$3` of its own stops the server at startup. A subscription is checked when it opens, and ended at its token's `exp` with `event: error` and `{"error":"the token has expired","status":401}`: the client takes it as a refused request, fetches a fresh token and opens the stream again (`FenecHttp.live`, `subscribe`, the sync layer, Go's `*Error` and .NET's `Event.Status`). Without it a stream outlived its token, and a member taken off a team heard the team until the client went away. No other request is held open past its token, nor before it: a read, a subscription or a `/_changes` page sent with `Fenec-After` waits only once its token is taken — one the server refuses is answered 401 at once, never after the wait — and is checked again once its wait ends, a write held by `Fenec-Wait` asks its token again each time it runs, and `/_changes` takes no scoped token. - **Refusals** tell a scoped token only what it may read: a `@unique` clash (409) names the field, not the document holding the value nor the value, since that row is another user's. - **Tenants.** On a `--dir` node a token reaches `/t//` only when its `tenant` claim names `` (a text, or a list holding it), and is refused with 403 under every other tenant's prefix; one without the claim is refused there unless the node runs with `--jwt-unbound-tenants`, and one holding it as `null`, `""` or anything else naming no tenant is refused even then ([Tokens](https://fenecdb.com/docs/sharding-reference#tokens)). The server's own `--http-token` keeps full access alongside. ## Keys, rotation and identity providers To accept tokens from Auth0, Clerk, Firebase or another identity provider, give `--jwt-keys` its published key file. Edit the file to rotate keys; no restart is needed. ```bash # the provider's public keys, as it publishes them curl -s https://example.auth0.com/.well-known/jwks.json > jwks.json fenec-server --file data.fenec --http 0.0.0.0:8080 --jwt-keys jwks.json --policy policy.txt ``` How keys, algorithms and token checks work, timed `--jwt-keys` takes a JWKS file, `{"keys": [...]}`, in place of a secret. An `oct` key verifies HS256 tokens and needs at least 32 bytes. An `RSA` key of at least 2048 bits verifies RS256 tokens, which is what Auth0, Clerk, Firebase and most identity providers sign with. A token issued by one of them is taken with its public keys alone. Keys of any other kind (EC, OKP) are skipped. A key is only used for its own algorithm: an RSA modulus is public, and taken as an HS256 secret it would let anyone sign. A token that names a `kid` is checked against that key only. A token without one is checked against each key of its algorithm. The server looks at the file at most once a second and reads it again when it has changed, so keys rotate without a restart. To rotate: - Add the new key to the file. - Start signing with it. - Once the tokens signed with the old key have expired, remove the old key. If the file can no longer be read, the server keeps the keys it read last and logs why. `--mint-token` signs with the first `oct` key and names its `kid`. A token that has already been verified is kept by its text, so that later requests with it skip the check. The server keeps up to 4 096 tokens, and the kept tokens are dropped whenever the keys change. Checking a token from that cache takes 0.3 µs for HS256 and 0.5 µs for RS256. Checking a new one takes 2.9 µs for HS256 and 38 µs for RS256 over 2048 bits (Apple M1). --- Source: https://fenecdb.com/docs/limits # Limits What fenecdb does not do, the ceilings in its code, and the behaviour worth knowing before it surprises you. ## Out of scope Before you choose it, at a glance: - **FenecQL, not SQL.** `select a from t`, `insert into t (a) values (1)`, `update t set a = 1`, `in (…)`, `between`, `like` and a `join` on an indexed key work ([From SQL](https://fenecdb.com/docs/sql)); a join of any other condition does not. `lookup` covers related rows (below). - **One writer per file.** Readers run beside it, and a `/batch` holds the writer while it runs. Scale out with a file per tenant ([Sharding design](https://fenecdb.com/docs/sharding-design#a-file-per-tenant)). - **Indexes live in memory.** The data stays in the mapped file; the indexes are restored or rebuilt on open ([Memory and scale](#memory-and-scale)). - **Typed fields, and `json` where a shape is not fixed.** A `json` field holds objects, lists and scalars, and a path (`meta.lang`) reads into it ([Objects and paths](https://fenecdb.com/docs/fenecql-schema#json)). - **No TLS.** Put a TLS terminator in front of any port off the machine ([Server](https://fenecdb.com/docs/server)). Deliberately not done: JOIN, SQL as the query language, several writers to one file, multi-writer replication, a field's type changed in place (it would rewrite every document under the write lock). Not done yet, and planned: TLS, in the repository's `ROADMAP.md`. A row's expiry is [`@ttl`](https://fenecdb.com/docs/fenecql-indexes#ttl), a filter by another collection's rows is [`in (get …)`](https://fenecdb.com/docs/fenecql-lookup#subqueries), a unique field is [`@unique`](https://fenecdb.com/docs/fenecql-indexes#indexes), a field is added, dropped or renamed with [`alter collection`](https://fenecdb.com/docs/fenecql-schema#changing-a-collection), and nested objects are a [`json` field](https://fenecdb.com/docs/fenecql-schema#json). Relations, one-way replication and type gaps These are consequences of the design, hard-coded ceilings, and behaviours it is cheaper to read about than to discover. The few that are planned say so. Relations are [`lookup`](https://fenecdb.com/docs/fenecql-lookup#the-lookup-clause) rather than JOIN: it attaches a collection's matching documents to the row they belong to, which is one bucket probe per parent and a `limit` that counts children per parent. It chains up to 8 levels, and `required` lets the children decide which parents appear. What it still does not give is a predicate spanning both sides — `products.price > reviews.cost` cannot be written, because the positional scoping that keeps qualified names out of the language leaves a predicate no way to say it reads two collections. That stays a second query. Replication exists in **one direction only**: the server is authoritative and clients read it. A replica's writes wait in a queue kept in its own file (`_sync_queue`), in the browser and on a phone, and reach the server one at a time, each once under its `Idempotency-Key`; the server answers each, and a write it refuses is put back ([Sync](https://fenecdb.com/docs/sync#refusals-retries-and-offline), [mobile](https://fenecdb.com/docs/mobile#sync)). Multi-writer merging and CRDTs are out of scope — that is what fits the single-writer model, and the other would be a different database. There are deliberate gaps in the type system too: - **No decimal.** `float` is binary floating point and unfit for money. The answer is an `int` in cents. - **No UUID type.** `text @hash` is functionally enough. - **Objects only in a `json` field.** A typed field still refuses one, and a list of a type holds that type alone. A `json` field holds any value JSON does; a path filters, orders and is indexed (`@hash`, `@unique`, `@sorted`), but an aggregate and `group` read a field, and text, vector and sparse indexes stay on typed fields. ## Fixed limits Ceilings baked into the code. Exceeding any of the first seven makes the query **error**, because a silently cut result is a wrong answer believed right; so does going past the two `json` depths. | Limit | Value | Note | | `near`, `match`, `fuse` result (`limit + offset`) | **10 000 rows** | a query error when exceeded, by a number written or a parameter's (`limit $2`); without `limit`, the default top-k applies | | words one word of a [`match ... fuzzy` or `prefix`](https://fenecdb.com/docs/fenecql-search#typos) reaches | **10 000** | the word itself among them; a query error naming the word past it, never some of them taken — a search box sends its second letter, or its first over a smaller vocabulary (FiQA's 80 426 words: `s` reaches 7 020) | | edits `fuzzy N` takes | **2** | and only what a word's length allows: none under 5 characters, one up to 8; past 2, a parse or query error | | `rerank` candidates, `fuse` candidates a side | **10 000** | a query error when exceeded; never fewer than `limit + offset` | | expression depth | **512 levels** | parentheses and `and`/`or` chains count | | `lookup` chain depth | **8 levels** | a query error when exceeded; the parser, the check and the engine all recurse per level | | fields of a [`lookup` key](https://fenecdb.com/docs/fenecql-lookup#the-lookup-clause) | **8** | as many as an index over several fields has parts, whose first ones they must be; refused past it, naming the index to declare | | `in (get …)` values | **100 000** | distinct values, each counted once however many rows hold it; a query error naming the bound when the inner `get` finds more; never a set cut short | | a policy rule's [`in (get …)`](https://fenecdb.com/docs/http-access#scoped-access) values | **1 000** | as a list claim's: answered before each statement its token sends, so a query error naming the bound past it, never a list cut short | | `in (get …)` nesting | **4 levels** | a query error when exceeded; each level runs a query before the one around it | | values of one `facet` | **10 000** | a query error when a facet without `top` finds more, and for `top` past it, written or a parameter's; never a list cut where it stopped | | ranges of one `facet ... ranges` | **10 000** | a query error past 10 001 bounds; every range answers | | `json` value nesting | **64 levels** | objects and lists inside one another; a write or a JSON text deeper is refused, never cut | | path length | **64 keys** | past its field (`meta.a.b` is two); a query error, since no value nests deeper | | fields of an [index over several fields](https://fenecdb.com/docs/fenecql-indexes#composite) | **8** | refused past it, and under 2: one field's index is its own `@sorted` or `@unique` | | spans the `in` lists over an index's parts make | 256 | the elements of each, multiplied; past it the list is tested row by row, never cut | | session stack (`fenec-server`) | 8 MiB | virtual; the deepest expression wants ~750 KiB in release, ~5 MiB in debug | | segment size | 8 MiB | sealed when full, a new one is opened | | document payload, segment offset | 4 GiB | the location record is a `u32` | | vector index node count | 232 | the node id is a `u32`; memory runs out first in practice | | level-0 degree (`m0 = 2m`) | 65 535 | `u16` counter, so `m` ≤ 32 767 | | bulk insert batch | 64–512 vectors | `graph/16`, clamped to this range | | parallel build threshold | 1024 vectors | below it, the serial path | | gap in an externally given `id` | 4096 | a larger jump falls into a sparse map rather than a dense array | | `timestamp` | 1 ms, i64 | ±2.9×108 years; microseconds truncated, always UTC | | [`bucket` by a named zone](https://fenecdb.com/docs/fenecql-reading#functions) | from 2000 | IANA's rules of the build's tzdata (2026c); a time before 2000 is a query error, never answered by rules the build does not hold; an offset written out (`'+03:00'`) answers any time | | `vector` | ±65 504 | ~3 decimal digits; outside it, values silently become `inf` or 0 | | `geo` point | ±180°, ±90° | two `f64`s kept as written; past either range a write is refused, never wrapped | | `sparse` dimension | 109 | pgvector's; the weights are `f32`, finite, and a zero is dropped | | change ring | **4096 entries** | `--changes`; how far behind a subscriber may fall, 24 bytes each | | concurrent subscriptions | **64** | `--http-max-streams`, a database's; each is a connection and a thread; on a tenant node, each tenant's own, and one of the `--max-connections` every tenant shares | | a tenant's file (`--dir`) | none unless set | `--tenant-max-bytes`, a tenant's own `bytes`; a write that would take the file past it is refused (507) before it lands; deletes, drops and `compact` run | | a tenant's rows | none unless set | `--tenant-max-rows`, its `rows`; its collections together, the server's own (`_`-named) left out; 507 | | a tenant's streams | a quarter of `--max-connections` unless set | `--tenant-max-streams`, its `streams`: subscriptions, `/_changes` waits and `Fenec-After` waits together; 429 | | a tenant's held writes | a quarter of `--max-connections` unless set | `--tenant-max-held`, its `held`: writes held until they can write (`Fenec-Wait`); 429 | | a tenant's requests in flight | none unless set | `--tenant-max-requests`, its `requests`; 429 | | a tenant's rows written a second | none unless set | `--tenant-writes-per-sec`, its `writes`; charged the rows each write lands, a write of none costing none; 429 while in debt, `retry_after_ms` to the millisecond | | a tenant's bytes written a second | none unless set | `--tenant-write-bytes-per-sec`, its `write_bytes`; charged as each write lands, 429 while in debt | | rows an MCP tool answers | **100** | `--mcp-max-rows`; past it the call is refused (413) with what to narrow, never cut ([Agents and MCP](https://fenecdb.com/docs/mcp#limits)) | | bytes an MCP tool answers | **64K** | `--mcp-max-bytes`, the answer's JSON; refused past it (413) | | MCP tool calls a second | **20** | `--mcp-calls-per-sec`, a scoped token's subject or an address; 429, with when to call again | ## Memory and scale The documents stay in the file and the indexes are held in memory, so a server's memory follows its indexes, not its data. The largest of them is usually the vectors. - **A 1 GB file** of 2.3 million rows with two indexes holds 188 MB mapped. - **One million 768-dim vectors** take about 3 GB, 1.5 GB as `f16`, 772 MB as int8 codes and 105 MB as bit codes. - **Set `--max-memory` to a third** of the container's memory. What a file costs to hold, measured **The documents stay in the file and the indexes are in memory.** The file is mapped ([The file is mapped](https://fenecdb.com/docs/concepts#the-file-is-mapped)), so a document is read from the pages the operating system holds and can drop; what the process holds is the offset index, the hash, ordered and text indexes, the graph, and the documents written since the open until they amount to 16 MB and are handed over to the file — a large `/batch`'s spilled into it as it goes. - **What a file costs to hold.** A 1 GB file of 2.3 million rows with a hash and an ordered index: 188 MB mapped, against 1 095 MB read into memory (`--no-mmap`). A 10 GB file of 23 million rows opens on an 8 GB machine, which reading it cannot. - **`compact` peaks at the indexes, not the data.** On that 1 GB file: 236 MB and 2.08 s mapped, 2 012 MB and 1.52 s read — the mapped one writes the live records into the new file and points at it, copying nothing and rebuilding no index but a graph holding tombstones. It takes longer and holds a ninth of the memory. A `checkpoint` is the same path with the dead records kept. - **Open cost is proportional to data size, not constant.** The graph is restored, reading every vector, and the offset index is built: mapped, from the index of its records the last checkpoint wrote, and walking the records written since; read into memory, walking them all. The hash, text, ordered and sparse indexes are built from the documents by the first query that reads each. 100 000 × 128 with the graph in the file opens in about 9 ms, which a single vector query makes up against SQLite's 30 ms scan. The vector arena is the largest item: one million 768-dimension vectors is about 3 GB, or 1.5 GB with `f16`, 772 MB as int8 codes and 105 MB as bit codes ([`quant=`](https://fenecdb.com/docs/concepts#quantized-indexes)) — the documents keep their vectors either way. In the browser nothing is mapped: there the whole file is in memory, inside WASM32's 4 GB address space, which is the real ceiling there. `Database::memory_bytes()` sums the segment bytes, the offset indexes, the vector arenas, the graph links, and the text, sparse, hash and ordered indexes, and `--max-memory` uses it before every write that grows the data. Each index keeps its count as it changes, so asking costs nothing next to the write: summed over every term instead, a text index of 200 000 documents took 0.42 ms a call. It is not RSS: allocator slack, HNSW build buffers, upper-level neighbour allocations (~3% of level 0) and the transient peaks all sit outside it. On the 1 GB file above, opened mapped, it counts 197 MB where the heap holds 188, 34 of them the hash index. |   | measured footprint | peak RSS | ratio | | 100 000 × 128 | 128.2 MB | 172.9 MB | 74% | | 200 000 × 4 | 57.6 MB | 97.2 MB | 59% | So the ceiling is an early warning, not a guarantee. A third of the container memory is a sensible start: it covers both this 60–75% ratio and `compact`'s 3× peak. Past one machine the data is split by tenant, not by row: each tenant is a file of its own, nodes hold many, and a router forwards each request to the right one ([Sharding design](https://fenecdb.com/docs/sharding-design#why-tenants-and-not-hash-partitioning)). The bound moves from "the database fits one machine" to "each tenant does" — a single tenant larger than a node is still out of reach. A single file has [read replicas](https://fenecdb.com/docs/replication), but a tenant node does not yet, so a node that is down takes its tenants with it. ## Index build, open and maintenance Indexes are rebuilt or restored when a file opens, and the vector graph is saved to the file at checkpoints, never on each write. A crash or a skipped checkpoint costs time at the next open, never data. - **A server answers while it relinks** vectors written since the last checkpoint, and keeps its graphs in the file as they change. - **A server builds an index or compacts beside the database,** so reads and writes wait milliseconds, not the whole build. - **Deleted or changed vectors stay in the graph** until `compact`, slowing `near` a little. Graphs, compaction, batches, ordered indexes and buffered writes - **The graph is derived data and is written only during `snapshot`, `compact` and `checkpoint`** — never on the write path. Without it in the file it is rebuilt on open: 0.90 s every open at 20 000 × 32, against 0.01 s with it. - Who writes a checkpoint: the `fenec` shell when leaving an interactive or piped session, and `fenec-server` on the shutdown signal — both only if a vector index exists. **A one-shot `fenec file.fenec -c '...'` does not.** Neither does `kill -9` or a cgroup OOM. The next open then restores the graph the last checkpoint wrote and applies the writes since it, so what is lost is time, not data: with 1 000 writes after the checkpoint, 0.07 s at 100 000 × 128 and 0.29 s at 100 000 × 768. The time grows with the writes since the checkpoint, and a server does not wait for it: their vectors go in unlinked, `near` measures each of them, and a thread beside the queries links them. At 100 000 × 768 never checkpointed, `fenec-server` answered its first `near` 1.23 s after it started, against 67.7 s when the open linked them first, which now takes 18 s; `near` takes the exact scan's 10.5 ms until the 19 s of linking are done, and 0.31 ms after. A server also keeps its graphs in the file as they change, so what waits is what came after the last of them: at 100 000 × 768 at most 10 000 vectors, linked in 2.6 s with `near` at 1.56 ms meanwhile, where every vector took 19.1 s in the same run. - **`compact` rebuilds a graph only when it holds tombstones.** The dead records go and the other indexes stand; a vector graph with a deleted or rewritten document's node in it is built again from the live vectors, which is what a `create index` of it costs — 50 s at 100 000 × 768. - **A server builds an index or compacts beside the database.** A `create index` or `compact` sent on its own to `fenec-server` or `POST /query` copies what it reads under the read lock, builds with no lock held, and takes the write lock only to apply the writes made meanwhile and put the result in place. At 100 000 × 128, where either takes about 20 s, reads waited at most 21 ms through an HNSW build and 30 ms through a compact — the compact's includes rewriting the file — against the full 20 s under the write lock, and writes went on at the same pace. Over a mapped file the compact copies no record: the graphs holding tombstones are rebuilt beside the database, the live records are written into a side file with no lock held, and the write lock is taken only to add the documents written meanwhile and rename it into place. Over 1.8 million × 128, a 1.1 GB file, writes waited at most 24 ms, where the rewrite under the lock held them 1.22 s, for 67 MB more heap at the peak. The records those documents superseded stay in the new file as dead ones until the next compact. One in a batch, or run by the shell or an embedding program, still holds the write lock throughout. A schema change to the same collection while it builds — another index, a drop — fails it with a request to run it again, since what was built no longer fits, and so does a second compact while one writes its file. It cannot be cancelled once begun. - **A file compacts itself once half of it is dead.** The server (`--auto-compact`, [Compaction](https://fenecdb.com/docs/server#compaction)) and the native library (`FENEC_OPEN_NO_AUTO_COMPACT` turns it off) look every 5 s and compact beside the queries once the dead records are half the file and 64 MB, so a file under updates stays under about twice its data, and its peak while the compact writes is the old file and the new one together. A file that reaches three quarters of the memory the system has for its pages is compacted sooner, once a third of what the compact writes is dead, so its reads stay in memory: up to four bytes written a byte updated against two. Over a million 1 KB records updated at 150 000 a second, each compact held the write lock 6 to 13 ms. An embedding program calls `compact_when_due` from a thread of its own or starts a `Compactor`; the browser module has no thread to run one, and drops the dead records from each image it persists. - A delete is a tombstone and the graph node stays in memory until `compact`. Changing a document's vector also marks the old node deleted and adds a new one, so in collections whose vectors change, the graph grows until compaction — and the tombstones take places in the beam `near` walks. An update of the other fields leaves the node where it is: 0.006 ms for a title at 20 000 × 768, where taking the vector out and back in cost 1.89 ms and a tombstone. A page tombstones cut short is walked again with the beam wider by their number, or searched exactly where that walk would cost more than reading every vector, so the answer is whole and only slower until the compact. - Documents inside one batch cannot see each other as neighbours. The batch is 1/16 of the graph size (64–512), so there is no measured recall loss — but very small collections fall to the serial path below 1024 vectors. - The build is parallel within a batch, and because writing links is serial the speed-up is not linear in core count: about 1.9× on eight cores. - **Ordered indexes are rebuilt from the documents and cost on write.** `@sorted` keeps about 20 bytes a row for a number, plus the text for a `text` field, and none of it is in the file: two ordered indexes over a million rows took about 40 ms to build, which the first query reading each after an open pays, and took a bulk load from 0.52 s to 0.88 s. A key that grows with the id, a creation time, is appended; a random one is inserted into a sorted run of at most 512 entries. - File writes are buffered. If the process crashes before `sync` or `checkpoint`, the last writes can be lost. In embedded use calling `sync` is the caller's job. ## Query behaviour Which filters use an index, what `order` and `near` cost, and the few ways a query behaves differently from what you might expect. - **Only an `and` chain of equalities and ranges reaches an index;** `!=`, `~` and anything under `or` scan every row. - **`order` reads every match** unless it is over one `@sorted` field with a `limit`. - **`put` does not check that a vector is finite.** Edge cases of sparse near, filters, order and collation - **`near` over a `sparse` field ranks only the documents sharing a dimension with the query**, by dot product and exactly — it takes no `ef`. A document with no dimension in common scores nothing and is not returned, as a document holding no word of a `match` query is not; with weights of both signs, one that shares dimensions can score below zero and still be on the page. The cost grows with the query's non-zero entries: SPLADE expands a query to 40 to 65 of them, which reach most of the corpus. - **Only an `and` chain reaches an index** — equality (`=` or `in [..]`) over a `@hash` field or over `id`, and comparisons (`<`, `<=`, `>`, `>=`, `=`) over a `@sorted` field. `!=`, `~`, `has`, a comparison on a field without `@sorted`, and anything under an `or` is a full scan: every matching row is decoded and evaluated. `~` is unranked substring matching; ranked retrieval is `match` over a `@text` field, which does reach an index. - A `@hash` equality answers as the scan does, a float's `-0.0` and `0.0` as one value. Inside a list or a vector a `-0.0` keeps its own key, so an equality over a hashed list or vector holding one can miss what the scan finds. - A comparison reaches an ordered index only when its literal compares exactly as the scan would: `price < 12.5` on an `int` field, or an integer past 253 against a `timestamp`, is tested row by row. A range wider than half the collection is scanned rather than read from the index. - An `in` list reaches the index only when **every** element resolves to a value the field's type can express. One element it cannot — `year in [2024, "abc"]` — sends the whole list back to the scan, because a union missing an element's rows would be a wrong answer rather than a slow one. - A filtered `near` reads its filter's rows only until more of them match than the ANN would measure anyway (`ef × m0`, 3 200 at the defaults), so a filter matching many rows costs little: over 200 000 × 128, `year >= 2020`, 23% of the rows, takes 1.1 ms against the ANN's 0.14. **A selective filter is read in full** — 17.9 ms for 1% of the rows — and its set is searched exactly, so recall is 10/10. An index on the filtered field takes that scan away: 0.16 ms with `@sorted`. - **`order` reads every match, unless it is over a `@sorted` field and has a `limit`.** Only `offset + limit` rows are put in order — the rest are partitioned away in linear time — but every matching row's key is still read, since the first twenty cannot be known without seeing all of them. The cost follows the number of matches, not `limit`: over a million rows `order created desc limit 20` takes 26.8 ms, and 0.001 ms once `created` is `@sorted`, because the index is walked in order and the walk stops at the page. A filter the index cannot narrow is tested on each row the walk passes, and one that has not filled the page within an eighth of the collection hands the query back to the scan — at worst about 1.25× the scan alone. An order over two keys, or over a `float` field holding a `NaN`, which has no place in an order, takes the sorting path. Without `order`, a scan stops at `offset + limit` matches. - **Two collations: Unicode's root and Turkish.** `collate und` orders text as ICU's root collation does, for every script, and `collate tr` as its Turkish one; another language's own order — Swedish `å ä ö` after `z`, German phonebook order — is the root's until its tailoring is added. The order is ICU's exactly but for a mark that composes with its letter across another mark between them, or marks out of their canonical order, which ICU's normalisation finds and fenecdb sorts as marks of their own: five in 1 250 000 random pairs. A key collated in the query over a field declared in no collation is sorted rather than walked — 890 ms for a million names, against 374 ms by their bytes — and a comparison in `where` over it compares bytes; a field declared in the collation has both. In the browser a statement that needs a script's weights the module has not been handed is refused until it has them ([JavaScript](https://fenecdb.com/docs/javascript#collation)). - **`nocase` folds case, and only case.** Unicode's full case folding (Turkish's in `collate tr`), with no normalisation: `é` as one code point and as `e` and a combining accent are two texts, as are `résumé` and `resume`, and a full-width letter is not its ASCII one. It is a text field's alone, not a list's or a `json` field's, and a field is made `nocase`, or no longer, by being built again, not in place. A scan over one folds each row as far as it is compared — a name by the scan 5.5 → 6.8 ms over 100 000 rows — and an order without a `@sorted` index folds each row's key once, 8.5 → 16.2 ms ([Case-insensitive text](https://fenecdb.com/docs/fenecql-schema#nocase)). - `near` and `order` cannot be used together; `near` determines the ordering by similarity. - A query vector can be given three ways: a vector, a list, or pgvector's text form (`near embed '[1,0,0]'`), which is the natural one when typing by hand in the shell. None of the three fails silently — unparsable text and a non-numeric component both error. - **`put` does not check that a vector is finite.** A NaN or infinite component enters the index and makes distances meaningless; the check exists only on the `fenec import` path. In a `vector` field an out-of-range value silently becomes `inf` or drops to zero below about 6×10-8. - The browser module needs WebAssembly SIMD: Chrome 91, Firefox 89, Safari 16.4 (March 2023) or later. On an engine without it, `Fenec.open` fails with a `FenecError` that says so. - `now()` does not work in the browser: wasm32-unknown-unknown has no clock and the call errors. Pass the time as a parameter with `Date.now()`. ## fenec-server The server is one process over one file, reached over HTTP, and it has no encryption of its own. - **No TLS:** put a TLS terminator in front off the machine. - **No interactive transactions:** a `/batch` is the unit that lands whole. - **Too many connections are refused,** not queued. The server's limits on TLS, transactions and connections - **No TLS.** A token travels in the clear on an open network. Listening on a non-loopback address with no token is refused without `--insecure`. - **No transaction held open across requests.** There is one writer, and a transaction a client held open between its requests would hold every other write for as long as the client took. A `/batch` lands its statements whole or not at all, and an `Idempotency-Key` makes it safe to retry. A `compact` cannot be put back, so a batch holding one runs each statement on its own. - A running statement, such as a long `compact`, is not cut in the middle; a client that goes away does not cancel it. - **A ceiling, not a pool.** Every connection is an OS thread. `--max-connections` rejects the excess with 503 rather than queueing it; put a proxy in front if you want a queue. The refused client's request is read off after the 503 and its connection ended without a reset, so any HTTP library reads the answer and its `Retry-After`. A connection the process can make no thread for — at its thread limit, below the ceiling — is answered the same 503, with `Retry-After`, and counted (`fenec_connections_refused_total{reason="no_thread"}`). - A keep-alive connection silent for 60 s is closed (`--idle-timeout `, 0 for never). ## fenec-http The HTTP endpoint runs inside `fenec-server` and keeps to a plain subset of HTTP. Its routes, filters and statuses are on HTTP reference; this is what it leaves out. - **No TLS.** Without a token it binds only to the local machine. - **Bodies up to 64 MiB,** no chunked uploads, and paging by `limit` and `offset`. - **Subscriptions are one-way** server-sent events, not WebSockets. The HTTP listener's limits on bodies, batches and streams - **Not a separate process.** It opens as a second listener via `fenec-server --http`; there is no standalone binary, because two processes opening the same file would corrupt it. - No TLS. Without a token it will not bind a non-loopback address. The server's own token, `--http-token`, is a single shared secret with full access; per-user access with scopes and expiry is a JSON Web Token held to a policy ([Scoped access](https://fenecdb.com/docs/http-access#scoped-access)). - `Transfer-Encoding: chunked` is unsupported (411). The body is capped at 64 MiB and the header block at 64 KiB; above that, 413 or 431. - Pagination is `limit` and `offset` — no cursor, no `Range` header — each a number or, in FenecQL, a parameter (`limit $2 offset $3`), so every page is one statement. The response is built in one piece, so a very large `select` holds that much memory on the server. - A field whose name matches a clause name (`select`, `order`, `limit`, `offset`, `count`, `where`) cannot be filtered from the query string. `POST /query` and `fenec-server` are unaffected. - No `ETag`, no `Last-Modified`, no conditional requests. Responses are not marked cacheable. - **A subscription is one-way.** The server sends changes over SSE and the client writes with ordinary `POST`, `PATCH` and `DELETE`. There is no WebSocket; for the same job it would demand framing, masking and ping/pong. - A subscription refuses `order`, `limit`, `offset` and `count` with a 400: a shape is a set, not a window, and an incremental diff cannot express a moving one. - **A write to any collection wakes every subscriber.** That is a necessity rather than noise: the counter and the ring are shared across collections, so a subscriber to a quiet collection that did not wake would keep its cursor in place and be reseeded once other writes overflowed the ring. Waking moves the cursor forward, and no empty batch is sent for a change that does not match the shape. The cost is one read lock and an empty scan. - **A batch holding a compact is not one block.** A `compact` rewrites the file, which cannot be put back, so a `POST /batch` holding one runs each statement on its own, stops at the first error and reports how many were applied. Every other batch — a `create`, a `drop` or a `create index` among its statements — lands whole or not at all. ## Import `fenec import` copies one table into one collection, from SQLite or PostgreSQL, and can follow a PostgreSQL table as it changes. - **Checkpoint a SQLite file's WAL first;** one with an unprocessed WAL is not read. - **A vector with a NaN or an infinity stops the import** at that row. - **`--follow` needs `wal_level = logical`** and an integer key. What import does not read or follow - One table becomes one collection. Multi-table migration is out of scope; a `JOIN` on a key reads as a `lookup` afterwards. - `fenec import` is in the default build but is a separate feature. A binary built with `--no-default-features` has no such subcommand, and says so before exiting. - `--where` sees only the built-in functions; plugin functions cannot be used. The filter is evaluated without borrowing the target database, so that writing can happen in the same loop. - **A SQLite file with an unprocessed WAL is not read.** The main file shows stale data and stopping beats silently returning it. Run `sqlite3 "PRAGMA wal_checkpoint(TRUNCATE)"` first. `WITHOUT ROWID` tables are not read either. - No TLS to the PostgreSQL server read from, and no md5 authentication: scram-sha-256 or cleartext. - PostgreSQL `timestamp(tz)` values are truncated to milliseconds. - **A non-finite component in a vector column stops the import** and it says which row. A NaN makes a distance incomparable; even if the index accepted it, the result would be meaningless. - An import that stops halfway cannot be rolled back — there are no transactions. - **`--follow` needs `wal_level = logical` and an integer key** in the replica identity: a changed row has to be found again by id. It holds its file as its only writer, and its slot holds the server's log until confirmed — drop the slot when you stop following for good ([Following changes](https://fenecdb.com/docs/import#following)). - A column added to the followed table after the copy is left out; one the collection needs that is dropped from it stops the follower. --- Source: https://fenecdb.com/docs/file-format # File format A fenecdb file is a header followed by records, and an open reads it from start to end in one pass. ## Records Every record is `[kind][collection-id][length][body]`. These are the kinds: ```text "FENECDB\x01" [6][u64 counter][u64 body length] change counter (optional) [1][collection-id][length][schema] create collection [7][collection-id][length][next-id] id counter (optional), [index] an image's, and its data's index [5][collection-id][length][schema] schema change (index) [12][collection-id][length][change] a field added, dropped or renamed, [field][name] the field and its new name, [schema] and the schema after it [2][collection-id][length] drop collection (empty body) [3][collection-id][length][records] data [4][collection-id][length][field][graph] HNSW graph (optional) [8][0][length][history] replication history (optional) [9][0][length][records] a block of several records [10][0][length][records] a block's spill, before it lands [11][0][length][spills][records] a block that spilled, landing ``` Inside a data record, each document is `[op][doc-id][length][fields]`, each field a tagged value. A `sparse` field is `[10][N][count]`, then the indices, each as its distance from the one before, then the weights as `f32`. An object, in a `json` field, is `[13][count]`, then each member's key as a length and its bytes and its value tagged, ascending by key. **A record cut short is cut off the file, not only skipped.** Left in place, the first write appended after it is read back as the rest of it: the write was acknowledged, and the next open loses it. **The length is written even for an empty body** — a `drop` record has one — **and the reader must consume it.** Otherwise the leftover byte is read as the next record kind and the file stops opening. Torn tails, the sync log and sparse gaps The reasons for each exception to the plain layout are worth knowing before you touch the reader; the sections below give them. A sparse vector's indices are written as gaps because a SPLADE vector's sit close together, and a small gap takes a byte. A crash in the middle of an append leaves the last record cut short, in its header or its body: the open loads everything before it and cuts it off the file, so there is no separate recovery step after a crash. On Linux a sync goes to `.sync` beside the file: a log of fixed size holding what the file took since its last own fsync. An fsync of a file that grew commits ext4's journal as well, 356 us against 65 for bytes written where the file already reached, so a durable write syncs the log and the file is synced once the log is full. An open writes back into the file what the log holds and the file lost, before it reads it, then wipes the log's header; an entry carries a hash and the log names the file it was written for (its inode, and a hash of the bytes before the log's start), so an entry cut short ends it and a log beside another file -- one a restore or an import renamed into the place -- is ignored. A clean close syncs the file and removes the log. A tool that only reads the file (`fenec types`) applies none: after a power loss, open the database before you copy or inspect it. The file's format is the same with it or without. ## The graph record Kind 4 holds an HNSW graph's links, so an open does not have to build the graph again. It is derived data: if it does not match the documents, it is ignored and the graph rebuilt, so it can never lose data. - **It stores links, not vectors** — except a tombstone's vector. - **The last one wins,** and the writes after it are applied on top. - **Versions 5 and 6** are a checkpoint with nodes waiting to be linked, and a server's record in the tail. How a graph record is checked and written It is derived data, not a cache. On open, the version, the dimension, the precision and the link bounds are validated, and the live nodes against the documents: every one's document has to hold a vector, and every document holding one has to have a node. If any of it does not hold, the record is ignored and the index is rebuilt from scratch. A corrupt or stale graph therefore cannot cause data loss. It is written during `snapshot`, `compact` and `checkpoint`, and a server appends one to the file's tail now and then (below) — never on the normal write path. What it stores is the links, not the vectors: the vectors are already in the document records, and the expensive part is the build. The exception is a tombstone, the node a deleted or rewritten document leaves behind until `compact`: it still routes searches, but its document is gone or holds another vector, so the record carries the vector the node was linked with. The graph is restored where its last record is, against the documents as they stood there: a checkpoint's image holds one after each collection's data. Everything after that point is applied to it the way the write path applied it: the node of each document written since is retired, and the vector the document holds now goes in. A crash therefore costs the writes after the last graph, not the graph. An earlier record of the same graph is passed over — the open walks the record heads to find the last one first — since restoring it would read every vector again for nothing. A server keeps its graphs in the file as they change, since it writes a checkpoint only on its way down: once a graph has changed in 10 000 nodes since it last reached the file, has none waiting to be linked, and the file has grown by three times its record since, it is appended to the tail, the writes held off while it is written. The record holds the whole graph, which is why the growth counts too: a big graph written every few thousand writes would be written over and over for a little of it. It is written in version 6, whose layout is version 5's; a binary that does not know 6 rebuilds the graph rather than restore it against the documents the whole file left, which a vector rewritten after the record would not match. It is not a write: the change counter does not move, no replica is sent it, and a record cut short is cut off the file like any other. The next checkpoint leaves the ones before it out. A server does not wait for the tail. It opens with the tail's vectors in the arena but in no neighbour list, measures each of them in every `near`, and links them on a thread beside its queries. A checkpoint written before that is done marks those nodes as waiting: a node's flag byte, 0 for a live node and 1 for a tombstone, is 2 for one, in a graph record of version 5, which is written only then. The next open leaves them waiting again, or links them there when it is not a server's. An older binary does not know version 5 and rebuilds the graph, as it does from any record it cannot read. A graph over bit codes (`quant=bit`) also carries the centres its codes are taken from, since a restored graph has to code its vectors against the ones it was built with. Where the quantization's code goes, a bit graph writes 3; the number of centres follows, then each centre as `dim` little-endian f32s — none while the index still holds its vectors whole. A tombstone's vector is its code: the signs, the centre's number in a byte and two f32s. A bit graph written before the centres, with the vectors' own signs and code 2, is built again on open; a binary from before them does not know 3, and builds its own. ## Indexes in the schema A schema names each field's index by a kind byte. Only the graph is stored; every other index is built from the documents after an open. | kind | index | | 0 | none | | 1 | `@hash` | | 2 | `@hnsw` with its parameters | | 3 | `@text` with its parameters | | 4 | `@sorted` | | 5 | `@hnsw` over codes (`quant=`) | | 6 | `@inverted` | | 7 | `@text` indexing characters as well (`chars`), with the parameters kind 3 has | | 8 | `@unique`: a hash that refuses a value held twice | A field ordered in a collation (`name text collate tr`) is written with type tag 11 and the collation's code in front of its type: 1 for `tr`, 2 for `und`. A text field compared by its case fold (`email text nocase`) has type tag 17 in front of its type, after the collation's where it has one. A dropped field's place (`alter collection ... drop field`) is written where it stands as a field with no name and type tag 12: the documents written before the drop hold a value there, which a read passes over, and a document written after it a `null`. A document that ends before a field's place holds `null` there: it was written before the field was added. A `json` field is type tag 14, and after its index kind come the indexes on paths into it: a count, then each one's keys (`source.rank`) and its index kind as a field's is written. What the file holds for an index, and what old binaries refuse For every kind but the graph that byte is all the file holds: the hash, text, ordered and inverted indexes are built from the documents by the first statement that reads each after an open, so there is nothing of theirs to validate or to go stale — and a binary that splits text another way, as one before runs of characters did, builds its own text index from the same documents. An old binary cannot open a file whose schema uses a newer kind and reports `unknown index kind 4`, the same refusal as for a record kind it does not know. An older binary meets a type tag it does not know, or a collation code, and refuses the file, rather than read the field and order it in another order — or, meeting a `json` field, before it reads an object (value tag 13) it would have no reading of: unknown type tag 14. The value and type tags share their numbers, so 13 and 14 are new to both, where 11 and 12 are a collation's and a dropped place's, and 17 is `nocase`'s: a binary from before it refuses the file rather than compare the field's bytes and take a second spelling its `@unique` refuses. The collation's weights are not in the file: they are the binary's, and a `@sorted` index over the field is built from the documents on open. ## The change counter, and why it is at the front Kind 6 holds the change counter. It sits at the front of the file and is fixed width; its body length marks where the image's own records end. Why the counter is at the front and fixed width Record kind 6 sits at the front of the file and is fixed width. Both are deliberate. **At the front**, because at the end a corrupt or half-written tail would break opening. The end of the file is exactly where the graph stops, and the graph is derived data — a record that errors out in that region would take that tolerance back. **Fixed width**, because the body length is known only after the body is written. A placeholder can be filled in place, whereas a uvarint would mean shifting the whole image. The body length means "this is where this image's own records end". Every record after it was written after the checkpoint and carries the counter forward. Old files without the record load fine — the counter is then counted from the records. The reverse does not hold: an old binary cannot open a new file and reports `unknown record kind 6`. ## A statement's writes are one record All the writes of one statement or `/batch` are appended as one record, so a crash keeps all of them or none. Within one collection that is a data record (kind 3); otherwise a block (kind 9). How a block becomes one record Every write is a block: the documents a `put`, a `set` or a `del` writes, the statements of a `/batch` or of the browser module's `run` of several. Their frames are held back until the block ends and are appended as one record, so a crash cuts all of them off the file or none; a block that fails is put back in memory instead, and nothing of it reaches the file. Within one collection the record is a data record of as many frames — the form an image's data records have always had, so a binary from before blocks reads it — and a lone schema change is the record it always was. Across collections, or with a schema change among other writes, it is record kind 9, holding each write's record: a data record, or a create's, a drop's or an index's. A binary from before blocks refuses it with `unknown record kind 9`, and one from before 10.4 a block holding a schema change as corrupt. The change counter moves by the writes a record holds, not by one a record: a block of five writes is numbered by its last, and whatever counts records by their numbers — a replica, an archive — counts them that way. A restore to a change inside a block stops before it, since the database never stood there. ## A large block spills before it lands A block past 16 MB is written to the file in parts as it goes (kind 10, a spill), then landed by a record that names those parts (kind 11). A spill no land names is ignored. How a spill and a land work Held back until it lands, a block's frames were in memory twice — in the record being built and in the stores — so a load of 250 000 768-dim rows in one command peaked 1.57 GB over what it left. Once a block's frames amount to 16 MB, a server appends them as record kind 10, a spill, whose body is a block record's, and the stores read those documents from the file from then on. The block lands as record kind 11, which names its spills by where their bodies are in the file and how long they are, and holds its writes since the last. A load applies a spill only where a land names it: one that a rollback, a lapsed lease or a crash left is dead in the file, and the ones a file ends with are cut off, as a record cut short is. The land never travels: a replica and an archive are sent the block as the one kind 9 record it would have been. A binary from before spills refuses kinds 10 and 11 as unknown. ## The history record is not a write Kind 8 records which [replication](https://fenecdb.com/docs/replication) history the writes belong to. It is the one record that must not move the change counter. Why the history record moves no counter Record kind 8 says which history the writes belong to: a list of `(id, from change)` pairs, one per fork, and whether the file is a replica's. A replica is fed a primary's writes by their number on the change counter, so this is the one record that must not move the counter — a replica would be numbered one change off its primary. It is appended when a history forks or a replica follows one, and written into every image while it is set; a file that never had a replica holds none. An old binary cannot open a file with one and reports `unknown record kind 8`. ## The id counter exists because of compact Kind 7 keeps a collection's next document id, so a `compact` can never hand a deleted id out again. In an image it also carries an index of the data record after it, which makes opening faster. Why compact needs the id counter A document id is normally derived from the records: a replay takes one more than the largest id it saw. Compaction throws tombstones away, so that derivation falls short — the highest deleted id disappears from the image entirely and would be **handed out again** on the next open. An id coming back silently binds everything still holding it — a link handed out, a row a subscriber holds — to the wrong document. The record is per collection and comes right after the schema. Only `snapshot` writes it; the append path does not need it, because the tombstones are still there. Old files without the counter still load correctly. Behind the counter an image writes an index of the collection's data record, which comes right after it: the record's length, the bytes in it that are dead, and each live document's id, payload length and place, each written as its difference from the one before — four bytes a document for records of 590 bytes. A mapped open takes the offset index from it rather than walking the record's frames, whose heads each say where the next one starts: a chain of cache misses that was 5.5 ms of a 14.2 ms open at 100 000 × 128, which opens in 8.4 ms with it. An index that does not fit the record — another length, ids out of order, a payload past its end — is passed over and the record walked. Every reader of the record, older ones included, reads the counter and nothing after it. ## Crates and layout Where each part of the format and the engine lives in the source. ```text crates/ fenec-core/ storage, HNSW, plan executor, plugin registry, JSON, calendar arithmetic, change stream (no dependencies) fenec-ql/ FenecQL lexer and parser (core only) fenec-wasm/ the browser ABI (no wasm-bindgen) fenec-http/ HTTP/JSON endpoint: REST, raw FenecQL, SSE, replication, tenants (core + ql only) fenec-wire/ the PostgreSQL client the importer reads with fenec-import/ SQLite file reader, PostgreSQL COPY source, --follow fenec-server/ the server: HTTP, replication, tenants, the --follow mirror fenec-shard/ the tenant router fenec-cli/ the `fenec` shell, `fenec import`, `fenec types` web/ fenec.js (builder + sync layer), fenec.d.ts, fenec.test.js and fenec.sync.test.js, the browser console ``` ## Maintenance operations Four operations read or rewrite the whole file. A rewrite goes to a side file that is renamed into place, so a crash cannot leave it half written. | Operation | What it does | Peak memory | | `snapshot` | Builds the image in memory: what the browser persists, and what a tenant's move and a replica's first stream send | ≈ 1× the file | | `checkpoint` | Lands the graph in the file so the next open does not rebuild it; written into the new file as it is produced | the indexes | | `compact` | Drops the dead records, keeping every index but a graph holding tombstones: the documents are the same ones | the indexes | | open | Maps the file and builds the indexes from the documents | the indexes | How compact and a checkpoint rewrite the file A graph holding tombstones is rebuilt by `compact` from the live vectors: nothing else takes a tombstone out, and every write that changes a document's vector, or deletes it, leaves one. A graph without them is kept as it is. The rewrite goes to a side file and is renamed, so a half-written checkpoint cannot corrupt the database — and since an image is only ever whole, a record cut short inside it, or an image longer than the file, is refused as a damaged file rather than cut off as a crash's torn tail is. A server's `compact` writes its image beside the database into `.fenec.beside`, with no lock held; the documents written meanwhile go into it as a data record at the end of the image, and its header takes the counter as it stands when it is renamed into place. A side file left by a crash is written over by the next compact, and can be deleted. --- Source: https://fenecdb.com/docs/server # Server Run fenecdb as a server: one process over one file, reached over HTTP from any language. ## Start and query - Start the server on a file. ```bash fenec-server --file data.fenec --http 127.0.0.1:8080 ``` - Send it a query. ```bash curl -s -X POST localhost:8080/query \ -d '{"query": "get articles select title near embed $1 limit 5", "params": [[0.1, 0.2]]}' ``` Everything the server does goes over that one listener: queries and the REST routes, subscriptions, replication, change data capture, metrics and the admin routes of a tenant node. The requests are on [HTTP endpoint](https://fenecdb.com/docs/http), every route on [HTTP reference](https://fenecdb.com/docs/http-reference), and each language's way of sending them on Your language. One writer process, and a thread a connection One process writes one file. Two processes over the same file corrupt it, which is why everything a server does — HTTP, replication, the graph keeper, the sweeper of rows past their time ([`@ttl`](https://fenecdb.com/docs/fenecql-indexes#ttl)), the `--follow` mirror — runs inside this one process rather than as programs of its own. One thread per connection. Read-only statements run at the same time under a shared lock; writes take the exclusive one. HNSW search buffers are thread-local, so the same index can be searched in parallel — eight concurrent full scans on eight cores measured 4.3×. A `/batch` is one block: its writes land in the file as one record, or none of them do, and a replica applies it whole. There are no interactive transactions held open across requests — the database has one writer, and a transaction a client held open would hold every other write with it. An `Idempotency-Key` makes a write, or a `/batch`, safe to retry ([HTTP endpoint](https://fenecdb.com/docs/http)). With `--replica-of` the process follows another `fenec-server` as a read replica: it serves every read and refuses every write with 403. The flags, promotion and what a failover loses are on Replication. With `--dir ` instead of `--file` it serves a directory of tenant files, one per tenant under `/t//`; that mode, its `--admin-token` and `--idle-close` are on Sharding reference, and the router in front of it on Tenants and sharding. ## Flags | Flag | What it does | | `-f, --file ` | The database file. In memory without it. The server holds `.lock` while it runs, so a second process over the file — another server, `fenec mcp --file`, an app through the native library — is refused rather than let corrupt it. | | `--dir ` | One file per tenant in this directory ([Sharding reference](https://fenecdb.com/docs/sharding-reference#the-node-fenec-server-dir)). | | `--http
    ` | Where to listen. Default `127.0.0.1:8080`. | | `--http-token ` | Requires `Authorization: Bearer `. Also read from `FENEC_HTTP_TOKEN`, since `argv` shows up in `ps`. | | `--jwt-secret`, `--jwt-secret-file`, `--jwt-keys`, `--jwt-require-exp`, `--jwt-max-age`, `--jwt-tenant-claim`, `--jwt-unbound-tenants`, `--policy` | Tokens held to rules: which collections, which rows ([Security](#security)). | | `--sync off|always|` | When writes reach the disk. Default `250` ([Durability](#durability)). | | `--no-checkpoint` | Writes no checkpoint on shutdown. | | `--exit-on-stdin-close` | Shuts down as `SIGTERM` does once stdin ends. For an app that starts the server with a pipe on its stdin: however the app ends, `kill -9` too, the pipe closes and the server syncs, lets go of the file and exits 0. Stdin of `/dev/null` ends at once, and so does the server; a terminal is refused ([Durability](#durability)). | | `--no-mmap` | Reads the file into memory instead of mapping it. | | `--warm all|off|` | Builds the hash, text, ordered and sparse indexes after the open, beside the first requests, rather than on the first read of each. Default `all` for a file, `off` for a node's tenants ([Warm-up](#warm)). | | `--auto-compact |off` | Compacts a file on its own once this share of it is dead, beside the queries. Default `0.5` ([Compaction](#compaction)). | | `--max-connections`, `--idle-timeout`, `--max-memory` | [Limits](#limits). | | `--http-cors`, `--http-read-only`, `--http-max-streams`, `--http-keepalive`, `--changes`, `--idempotency-ttl` | The HTTP endpoint's options, each where it is described: `--http-cors` and `--http-read-only` under [Security](https://fenecdb.com/docs/http-access#security), `--http-max-streams` under [Subscriptions](https://fenecdb.com/docs/http-reference#subscriptions), `--changes` under [Dropped connections and the horizon](https://fenecdb.com/docs/sync#dropped-connections-and-the-horizon), `--idempotency-ttl` under [Retrying a write](https://fenecdb.com/docs/http#idempotency); `--http-keepalive` is in `fenec-server --help`. | | `--replication-token`, `--replica-of`, `--promote`, `--replication-buffer`, `--cdc` | [Replication](https://fenecdb.com/docs/replication) and [change data capture](https://fenecdb.com/docs/microservices). | | `--admin-token`, `--idle-close`, `--lease` | A tenant node's ([Sharding: reference](https://fenecdb.com/docs/sharding-reference#the-node-fenec-server-dir)). The admin token is also read from `FENEC_ADMIN_TOKEN`. | | `--follow `, `--follow-*` | Mirrors a PostgreSQL table into the file and serves it ([Import](https://fenecdb.com/docs/import#serving)). | | `--metrics`, `--slow-ms`, `--audit`, `--auth-delay` | [Monitoring](https://fenecdb.com/docs/monitoring) and the audit log. | | `--otlp-endpoint`, `--otlp-header`, `--trace-sample` | OpenTelemetry traces to a collector; off unless an endpoint is given ([Tracing](https://fenecdb.com/docs/monitoring#tracing)). | | `--studio`, `--studio-connect` | fenec studio, the admin pages, at `/_studio/`; off unless given ([Studio](https://fenecdb.com/docs/studio)). | | `--mcp`, `--mcp-write`, `--mcp-memory`, `--mcp-max-rows`, `--mcp-max-bytes`, `--mcp-calls-per-sec` | MCP for agents at `POST /_mcp`, each tool held to the request's token; off unless given ([Agents and MCP](https://fenecdb.com/docs/mcp)). | | `--insecure` | Allows a non-loopback address without a token. | | `--ping` | Asks the server whether it is up and exits ([health checks](#limits)). | | `-V, --version` | Prints `fenec-server ` on stdout and exits 0, before the file is opened or a port bound. Releases up to 0.1.12 have no such flag: the first line of `--help`, on stderr, names theirs. | `fenec-server --help` prints every flag with its default. Its exit code says how it ended: | Code | When | | `0` | It stopped as asked: a signal, or the end of stdin under `--exit-on-stdin-close`, after the final sync. A signal that comes before the server has opened its file ends it by the system's default action instead, with no code of its own. `--ping`: a server answers. `--version` and `--help`: printed. | | `1` | A server over a file or in memory could not start: its file could not be opened (another process holds it, or it cannot be read), `--follow`'s guard could not be put on it, a replica's file was started without `--replica-of` or `--promote`, its history could not be settled (a promotion's fork, or its sync, failed), or its HTTP port could not be had. Or `--follow`'s mirror stopped, before the server served or after, or once serving its listener failed — after the final sync and with no checkpoint. `--ping`: none answers. | | `2` | A flag or its value was refused, before anything was opened: an open address with no token among them. Also a node's directory that could not be used, a node's HTTP port or `--metrics`' that could not be had, and a thread the start could not make (after the final sync, with no checkpoint). | A start that fails rewrites nothing. The ports are taken straight after the open, before anything writes the file or runs beside it, so a failed start has written only `.lock` and what an open writes — the file, or a node's directory, made empty where there was none, a torn tail cut, and on Linux the writes a crashed server had made durable written back from its sync log — and a supervisor restarting it again and again changes nothing more. A server that stops on a failure writes no checkpoint either: the next open links what the graphs kept in the file left. Between the ports being taken and the server answering — the open of a node's tenants, a moment for a single file — a connection waits in the system's queue rather than being refused. ## Security Give the server a token, or give each user a JSON Web Token that names what they may read and write. ```bash fenec-server --file data.fenec --http 0.0.0.0:8080 --http-token "$TOKEN" ``` **There is no TLS yet.** On a network, put a TLS terminator such as Caddy, nginx or a load balancer in front. Tokens, JWTs and the audit log `--http-token` is the server's own token: whoever sends it reads and writes everything. The comparison is constant time. Listening on a non-loopback address with no token is refused outright; it can be opened deliberately with `--insecure`. With `--jwt-secret` (HS256, at least 32 bytes) or `--jwt-keys` (a JWKS file: `oct` keys for HS256, `RSA` keys for an identity provider's RS256, a token's `kid` picking the key, the file read again as it changes) the server also takes JSON Web Tokens, each held to the rules of `--policy`: which collections, to read, insert, update or delete, which rows (`where owner = $jwt.sub`, `where room in $jwt.rooms`, the rows another collection names: `where courier in (get orders select courier where customer = $jwt.sub)`), which fields of them a read shows (`read(pickup, run_at) where run_at <= now()`, [Fields a read shows](https://fenecdb.com/docs/http-access#field-grants)), and what an update may make of a row (`where status = "ready" and new.status = "cancelled"`). A token that may only read is the reporting login a dashboard needs, one that may only insert a client appending events, and an `append-only` collection takes no update or delete from any token but the server's. The rules are ANDed into every statement a token runs, and every document it writes is checked against them as well ([Scoped access](https://fenecdb.com/docs/http-access#scoped-access)). `--mint-token '{"sub": "ada"}'` prints a token signed with the secret, for testing, which expires in an hour unless the claims name an `exp`. A token with no `exp` is refused, since it would be good for ever (`--jwt-require-exp off` takes it), and `--jwt-max-age ` refuses one whose `exp` lies further ahead than that. On a `--dir` node a token reaches only the tenant its `tenant` claim names ([Tokens](https://fenecdb.com/docs/sharding-reference#tokens)). Pass the secret with `--jwt-secret-file` or `FENEC_JWT_SECRET` — `argv` shows up in `ps` output. Every token and key is read from the environment too: `FENEC_HTTP_TOKEN`, `FENEC_ADMIN_TOKEN`, `FENEC_REPLICATION_TOKEN`, `FENEC_JWT_SECRET` and `FENEC_JWT_KEYS`. One set and empty -- an environment file's line left blank -- is no token. One given empty as a flag -- `--http-token "$TOKEN"` with `TOKEN` unset -- is refused at the start, as is a secret's file holding nothing: a request that carries no `Authorization` reads as the empty token, so an empty admin token let anyone into `/_admin/`. A request refused for its token waits before it is answered — 100 ms, twice as long for each refusal from the same address within a minute, 5 s at most (`--auth-delay`; 0 turns it off). A token the server verifies clears its address's count; an answer that looked at no token, whatever `Authorization` it carried, clears nothing. `--audit ` appends a JSON line for each refused request, each change of the schema and each admin request ([The audit log](https://fenecdb.com/docs/monitoring#audit)). ## Durability Choose how often writes reach the disk with `--sync`. | `--sync` | What it means | | `250` (default) | Flushed every 250 ms. A crash loses at most that much. | | `always` | Each write is answered once it is on disk. | | `off` | Flushed only on shutdown. | What each --sync policy costs, and how the server shuts down Writes accumulate in a 1 MB buffer. Under `always` every write waits for an `fsync`, about 3.7 ms on macOS for a write alone, and writes arriving together share one; `250` costs next to nothing; `off` nothing. A `/batch` lands with one `fsync` however many writes it holds. Under `always` the `fsync` runs after the write has let go of the exclusive lock, and the bytes reach the file there too. Readers therefore do not wait on the disk, and writes that arrive while one `fsync` runs are made durable together by the next. One client still gets one write per `fsync`. The sync of `250` takes the same path, for a single database and for each tenant of a `--dir` node: held through the `fsync`, a tenant's lock had its reads and writes wait up to 27 ms once an interval. `SIGINT`, `SIGTERM` and `SIGHUP` are caught and a final sync runs before exit. The exclusive lock is **held** until exit, so no write can be accepted between the sync and the exit and no write is left that looked successful to the client but never reached the disk. Under `kill -9` you get exactly what the chosen policy promised, and no more. With `--exit-on-stdin-close` the end of stdin starts the same shutdown. A process that is killed sends its children no signal, but the system closes every descriptor it held, so a server started with a pipe on its stdin sees the pipe end however its parent ended, and stops rather than run on holding `.lock` with nobody to answer. What the parent writes into the pipe is read and dropped. A terminal is refused: a server reading one from the background would be stopped whole by the system, its file held; there Ctrl-C does the same. So is a secret, policy or keys file that is stdin, which would be read to its end. ```js import { spawn } from 'node:child_process'; // stdin is a pipe this process holds for as long as the server runs. const server = spawn('fenec-server', ['--file', path, '--http', '127.0.0.1:0', '--exit-on-stdin-close'], { stdio: ['pipe', 'ignore', 'pipe'], env: { ...process.env, FENEC_HTTP_TOKEN: token }, }); // Read stderr for as long as the server runs: a pipe nobody reads fills, // and the server's next log line waits for room. `onListening` is yours, // called once with the address the server took. let head = ''; let url = null; server.stderr.setEncoding('utf8').on('data', (text) => { if (url) return; head += text; const at = /listening on: (http:\/\/\S+)\s/.exec(head); if (at) onListening((url = at[1])); }); ``` The pipe's write end must stay in this process alone. A child it starts later that inherits it — a descriptor not marked close-on-exec — holds the server's stdin open after this process has gone, and the server runs on. When the disk refuses a write or a sync, the request fails with 500 — under `always`, before the client is told it succeeded — and every later write is refused until the process restarts; reads go on answering. The sync is not retried: after a failed `fsync` the kernel may already have dropped the pages, so a retry that succeeds would prove nothing. **The shutdown checkpoint.** After the sync, if a vector index exists, a checkpoint is written: the HNSW graph lands in the file and the next open does not rebuild it — 9 ms instead of 3.4 s at 100 000 × 128. Vectors still waiting to be linked after a crash are written as waiting, and linked after the next open. The cost is that shutdown takes longer on a large database: the image is written into the new file as it is produced, so it is a copy on disk rather than one in memory. `--no-checkpoint` turns it off. The order is deliberate. The sync comes first, so even if the process is killed while the checkpoint is being written, the data is already on disk and only the graph is lost. The rewrite goes to a side file and is renamed, so a half-written checkpoint cannot corrupt anything. ## Warm-up An open builds no hash, text, ordered or sparse index: the first query that reads one builds it, and pays for it. `fenec-server` builds them after the open instead, beside the first requests, one at a time: the port opens as soon as before, and the first search finds its index built. `--warm products,orders.status` names the collections and fields to warm, `--warm off` none. How warm-up builds indexes beside reads, measured Each index is built under the read lock on its own, so reads go on beside it, a read that needs the index being built waits for that build rather than starting its own, and a write waits out one index at most (a `@text` index of 100 000 products: 141 ms). The thread starts once the HTTP endpoint is up, whose start takes the write lock. Over 100 000 products with a text, two hash and an ordered index, on an M1: the first answer 16 ms after the start either way, the indexes built 208 ms after it, and the first `match` 1.4 ms warm against 166 ms cold; the process held 99 MB against 88 for a cold one that had only searched, and 25 before any read. It is on for a file because an index is declared to be read, and a first query that builds one is the slowest a server answers: a shop's first search page took 2.68 s to its largest paint against 1.40 s warm. The memory is what the reads would have taken anyway; only an index nothing ever reads is held for nothing, and `--warm` names the rest. A node's tenants open on their first request and close when idle, and warming each would build every index of every tenant a request touches, so there it is off unless asked. The native library has the same call, `fenec_warm`, and the Rust API `Database::warm` and `warm_index`. ## Compaction An update writes the whole record again at the end of the file, and a delete writes a tombstone; the version left behind is dead until a `compact` writes the live records into a new file. The server runs one on its own: every 5 s it looks at each file it serves, the database, each open tenant and a replica alike, and compacts one once half of it is dead and the dead part is at least 64 MB -- or sooner, once a third of what the compact writes is dead, where the file has reached three quarters of the memory the system has for its pages or the server is waiting for the disk, so that its reads stay in memory. The compact runs beside the queries, and takes the write lock only to put the new file in place. | Flag | Default | What it does | | `--auto-compact ` | 0.5 | The share of the file that may be dead before a compact runs. | | `--auto-compact off` |   | Compacts only on `compact`. | Why a file compacts itself, and what it costs Left alone, a file of updated rows grows by a record a write, and its live records end up a few to a page among dead ones: under YCSB's updates, a million records of 1 KB took the file to 4.7 to 6.5 GB on an 8 GB machine, and one read in five then waited for the disk. With the default ratio the file stays under about twice what the last compact left it at, and each compact writes the live records once for as many bytes of updates. What it costs, measured with `make compact-bench`: a million 1 KB records, five million updates of a field at keys drawn at random from one thread, buffered, while another reads whole records. With auto-compaction the file went from 1.03 GB to between 2.1 and 3.0 GB and back to 1.1 to 1.6 GB three or four times; each compact took 8 s at the median with no lock held and held the write lock 6.1 to 12.6 ms to put its file in place. The reads while a compact ran had a p99 of 1.3 to 5.3 ms. The updates ran at about 30 000 a second through a compact, against 110 000 to 170 000 while the file was smaller: on this 8 GB machine, beside its other programs, the page cache holds about 2 GB of the file, the size at which a compact comes due, and past it an update reads its row from the disk whether a compact runs or not -- 28 000 to 46 000 a second with auto-compaction off. At 250 000 records, whose file fits, the updates ran at 153 000 a second through a compact of 0.3 s, and at no less than 83 000 in its worst tenth of a second, against 180 000 with none. Once the updates were done the reads ran at 435 000 a second, p99 2.2 µs, from the first second. Without it the file ended at 6.2 GB, and the reads after the updates started at 19 000 a second and took 25 s to reach 400 000, their p99 0.11 ms over those 30 s. Durable, an fsync a write from 16 writers, 100 000 records updated 400 000 times at about 900 a second: the file went from 103 to 211 MB and back three times, each compact held the write lock 13.7 to 14.8 ms -- two `F_FULLFSYNC`s of this Mac's among it -- and ended at 190 MB against 515 without it; that file fits the page cache either way, and the reads ran alike, p99 2.2 µs. **Before the file outgrows the page cache.** fenecdb keeps no cache of its own: a document is read where it lies in the mapped file, so what the reads need in memory is every page of the file that holds a live record -- and under updates at keys drawn at random that is every page until a compact (a million 1 KB records updated until the file stood at 2.1 GB left one on every 16 KB page and on 94% of 4 KB ones). Once the file outgrows what the system has for it, every read and update waits for the disk in turn. So a file is also compacted once a third of what the compact writes is dead, where its live records fit in that memory and the file has reached three quarters of it -- which Linux reports exactly, its available memory and a container's limit -- or the server takes 500 major faults a second, which is how macOS shows it: under pressure it compresses the other programs' memory and reports more room than it gives the file. That costs up to four bytes written for each byte updated, against two at half the file dead. A compact whose new file would not fit beside the old one writes it past the page cache and reads it in after the swap, so the old file, which the reads need until then, stays in memory. Measured with `make compact-bench` in a container held to 2 GB (`docker run --memory 2g`): at half the file dead the file stood over 2 GB for 69 to 74% of the run, the five million updates took 358 to 511 s, 3 500 to 4 100 a second at the median, and the process took 811 000 to 900 000 major faults; with the memory rule, 80 to 100 s and 34 000 to 39 000, the file at most 1.9 GB, the reads during a compact p99 0.44 to 0.49 ms against 4.6 to 6.5, for 3.0 to 3.2 bytes written a byte updated against 1.9 to 2.0. On this Mac beside 1 GB of memory held by another program, 191 to 260 s against 82 to 535: macOS gives a growing file more of the machine as it compresses the other programs' memory, which it did in the fastest run at half the file dead and not in the slowest, and a file kept smaller is not given it -- steadier, never the best case. A file that fits in memory is compacted as before. The compact copies what it reads under the read lock, writes the live records into a side file with no lock held, and copies the writes made meanwhile in rounds -- a few thousand documents looked up under the read lock at a time and copied once it is let go -- until a round finds fewer than a thousand; it reads every page of the new file in before the swap, asking for them ahead in large reads, so no reader waits for one after it -- unless the two files would not fit in memory together, when it writes the new one past the page cache and reads it in once the old one is gone. The write lock is then held for the last of those writes, the side file's fsync and the rename. The server looks for a compact due every 5 s, and a look waits for a write under way rather than pass a compact over. A replica compacts its own file the same way: a compact changes no document, and an image its primary sends meanwhile fails the compact rather than be written over. `/_metrics` has the file's size, what a compact would give back of it and the compacts run ([Monitoring](https://fenecdb.com/docs/monitoring)), and each is logged with the file before and after and how long it held the lock. ## Limits and health checks | Flag | Default | What it does | | `--max-connections` | 100 | Refuses connections past it. | | `--max-memory ` | off | Refuses writes that would grow the data past it; reads go on. | | `--idle-timeout ` | 60 | Closes a quiet keep-alive connection (0 = never). | | `--http-max-streams` | 64 | Ceiling on a database's subscriptions, counted apart; on a tenant node, each tenant's. | ```bash fenec-server --ping --http 127.0.0.1:8080 # exits 0 when the server is up ``` Connection ceilings and health checks One thread per connection means unlimited connections would leave the stack memory ceiling to the client. Past `--max-connections` a client gets 503 with `Retry-After`, its request read off and the connection ended after the answer, so a client writing its request as the answer comes reads the 503, not a reset; `--idle-timeout` closes a quiet connection. A subscription holds a thread for as long as it lasts, so subscriptions are counted apart as well, under `--http-max-streams`, a database's own: on a tenant node each tenant's, where `--max-connections` is what all of them share, and each tenant's streams and held writes take a quarter of it at most unless its quota says otherwise ([quotas](https://fenecdb.com/docs/sharding-reference#quota-bounds)). `--max-memory` puts a ceiling on the data footprint. Above it, requests that **grow** the data are refused with 507, while reads, `del` and `compact` keep working: hitting a ceiling with no way out would be no better than a cgroup OOM. The point is to bring the OOM forward: nobody warns you about a process felled by `SIGKILL`, and both the last sync and the checkpoint are lost with it. The measurement happens before the statement, so the overshoot is at most one statement. `--ping` asks `GET /_health` on the `--http` address and exits 0 when it answers. The route needs no token and takes no lock: every query takes the database lock first, so during a long `compact` a probe that ran one would wait too and a healthy server would look dead. Because the listener binds after the file is opened, "healthy" also means "the documents are read". What the server is doing is at `/_metrics`, in Prometheus's text format: `--metrics
    ` opens a listener for it alone, and `--slow-ms ` logs every statement at least that slow with its text, a JSON line on stderr. The metrics, a Grafana dashboard, the log and the Datadog Agent's configuration are in [Monitoring](https://fenecdb.com/docs/monitoring) and [Datadog and other monitoring systems](https://fenecdb.com/docs/datadog). ## Run it in a container The image is under 3 MB: the binary and nothing else. ```bash docker run -d --name fenecdb -v fenecdata:/data \ -p 127.0.0.1:8080:8080 -e FENEC_HTTP_TOKEN=a-token \ ghcr.io/fenecdb/fenec-server ``` The image's command is `--http 0.0.0.0:8080 --file /data/data.fenec --sync 250`. - **Set a token.** Without one it refuses to listen beyond localhost. - **Run one instance per file.** Two writers corrupt it: on Kubernetes, a `StatefulSet` with one replica. - **Write every flag out** when you override the command, or the data file is dropped. How the image is built, and what sets its memory and restart cost The `Dockerfile` is two-stage: a static musl build copied into `scratch`. There are no dependencies and the binary runs its own health check (`--ping`), so the runtime image holds nothing but the binary — no shell, no package manager, no libc. The image is **2.99 MB** on amd64 and 2.44 MB on arm64 — since no target is written in, both come out of the same file. `make docker` builds it, and `docker compose up -d` starts the compose file's. **The memory limit comes from the data.** With no buffer pool of its own there is no knob to tune: the file is mapped, the system's page cache keeps its pages, and what the process holds is the indexes, which is what `--max-memory` counts. Below that the cgroup sends `SIGKILL`, the clean shutdown never runs and up to one sync interval is lost. Set `--max-memory` to a third of the container limit to bring that forward. **The restart cost depends on how it stopped.** `docker stop` sends `SIGTERM` and a checkpoint is written, so the next open takes 9 ms. After `kill -9` or a cgroup OOM the graph of the last checkpoint is restored, and the vectors written since it go in unlinked: the server answers once the documents are read, and links them on a thread beside its queries, `near` measuring each of them until then. The only checkpoint the server writes on its own is the one at shutdown, but it appends its graphs to the file as they change — a graph that changed in 10 000 nodes, once the file has grown by three times its record, logged as `1 graph written into the file`, with writes held off for the 5 ms that takes at 100 000 × 768 — so what waits is what came after the last of them: at 100 000 × 768 at most 10 000 vectors, linked in 2.6 s with `near` at 1.56 ms p50 meanwhile. With no graph in the file every vector waits: at 100 000 × 768 never checkpointed the first `near` was answered 1.23 s after the start, against 67.7 s when the open linked them first, and linking takes 19 s. `fenec_vectors_unlinked` counts down to 0. Overriding `CMD` drops `--file`, and the container comes up with an in-memory database. There is no shell in the image, so `docker exec ... sh` does not work; what is left is `docker logs` and `docker run --rm ghcr.io/fenecdb/fenec-server --help`. On machines of your own, the repository's `deploy/` has a systemd unit for a server of every role — a tenant node, a region's router, the global router, an archiver — with its tokens in an environment file, the alert rules and the operator's runbook (Deploying it). Two containers loading the same file and writing it corrupt it. On Kubernetes that means a `StatefulSet` rather than a `Deployment`, `replicas: 1`, `strategy: Recreate` and a `ReadWriteOnce` volume — a rolling update keeps two pods up together for a moment. There is a connection ceiling but no queue: an excess connection does not wait, it gets 503. ## More - **Mirror a PostgreSQL table.** `fenec-server --follow postgres://...` keeps a copy as the table commits, and serves it. See [Import](https://fenecdb.com/docs/import#serving). - **Embed it instead.** The same engine runs inside a Rust program with no server at all ([Embedded Rust](https://fenecdb.com/docs/embedding)), and in a browser tab ([JavaScript](https://fenecdb.com/docs/javascript)). --- Source: https://fenecdb.com/docs/studio # Studio fenec studio is the server's own admin page: its collections, their rows and their schema in a browser, the edits you make there, a query editor, the rows as they change and the server's own numbers. Start the server with `--studio` and open `/_studio/`, or try it on the [Playground](https://fenecdb.com/playground), where it runs over a database in your own tab. On a Mac there is an app too ([Desktop app](#desktop)). ## Turn it on Add `--studio` to `fenec-server`, open `/_studio/` on the same address, and paste a token. ```bash fenec-server --file data.fenec --http 127.0.0.1:8080 \ --http-token "$TOKEN" --studio open http://127.0.0.1:8080/_studio/ ``` 100 000 orders, counted by their `@hash` fields, one row unfolded. - **Off by default.** Without `--studio`, `/_studio/` answers 404, as an unknown path does. - **No new authority.** The page reads and writes with the token you paste, over the same HTTP surface every client uses. - **A tool for operators:** keep it on a private network or behind your own sign-in ([Security](#security)). How the studio is served, and its size The studio is plain ES modules and CSS from the repository's `studio/` folder, with the HTTP client and query builder of `@fenecdb/web/client` beside them — no framework and no build step. The server embeds the files, so the binary is all you deploy. Its first load is 67 KB of JavaScript and CSS, gzipped, its modules preloaded together from the page; the query editor, the schema, the live rows and the admin view are fetched the first time each is opened, 7 to 19 KB more. The server sends the files as they are, uncompressed; a proxy in front may compress them. A router serves it too: `fenec-shard --studio` puts the same pages on the router's address, where each tenant is reached through the router ([Tenants](#tenants)). ## Connect The server is the page's own address unless you name another, and the token is the one the server takes: its `--http-token`, or a JSON Web Token its policy holds to its rows. The top bar says who the server takes it for. - **Your token stays in the tab.** It is kept in session storage and forgotten when the tab closes: never in local storage, a cookie or a URL. - **Who you are:** the token's subject, role and tenant, the rules that apply to it, and how long until its `exp`. The line under the top bar shortens as the token's life runs out, and the page warns you five minutes before it does. What /_whoami tells the page The identity comes from `GET /_whoami`, which any client may ask: `open` on a server that asks for no token, `full` for the server's token, and `scoped` for a JSON Web Token, with each rule of the policy that applies to it — the collection, the grants, the fields an update may change, the fields a read shows (`read_fields`, `null` for every one) and the rows, as the policy writes the filter. A token is shown its own rules and no other's: a rule for a role it does not hold is not among them. It grants nothing; a token the server refuses gets the same 401 here as anywhere. ```bash curl -H "Authorization: Bearer $ALICE" http://127.0.0.1:8080/_whoami # {"node":"single","tenant":null,"read_only":false,"kind":"scoped","sub":"alice", # "tenants":null,"unbound":false,"append_only":[], # "rules":[{"collection":"orders","grants":["read","insert","update","delete"], # "fields":null,"read_fields":null,"rows":"owner = $jwt.sub"}]} ``` The token's claims — its subject, role and `exp` — are read in the page to show them, and decide nothing: the server checks the token on every request. ## Collections and rows The sidebar lists the collections the token can read, each with its rows and its fields' types and indexes. The grid shows a collection's rows, as many as it holds, read a page at a time as you scroll. - **Sort** by `id`, or by a field an index orders: `@sorted` or `@ttl`. - **Filter** with a `where` clause in FenecQL and its parameters, or a box under each column: `abc`, `=v`, `>=v`, `a..b`, `null`. - **Counts by value** for each `@hash` field, over what the filter selects; pick one to filter by it. - **A row unfolded** (Space): a `json` field as a tree, a vector as its dimension, its length and its first components. - **File size** and what a compact would give back, where the token may read `/_metrics`. How rows are paged and drawn A page is 100 rows. In id order the page after one already read is asked for by its last id — `get orders where id > $1 limit 100` — which walks the id index from there; a page reached by a jump, or under an order, by its offset. Only the rows in view are drawn, so 100 000 rows cost what 40 do: a scroll down the whole collection and back in Chrome took no task over 50 ms (`make studio-test`). Past 8 million pixels of rows the scroll bar stands for the rows in proportion, since browsers cap an element's height. The count and the counts by value are one `/batch` of reads, under one read lock, so they are of one moment. A vector is never written out whole: 768 numbers in a cell are noise. ## Edit Enter writes a cell, N opens a form for a new row, and Delete removes one after you confirm the statement it will send. ```fenecql set orders {note: $1} where id = $2 require 1 insert orders {customer: $1, total: $2} del orders where id = $1 require 1 ``` - **Every value is a parameter.** A text shaped as FenecQL is stored as it reads. - **Every write is a `/batch`** with an `Idempotency-Key`, so a retry cannot write it twice. - **A refusal says why:** 403 names the field the policy refused, and 412 says the row changed — deleted, or no longer the token's — since it was read. How an edit is checked and written `require 1` makes a write that found no row a refusal (412) rather than a quiet `affected 0`, so an edit of a row someone deleted meanwhile is told so. The form is built from the schema: a box a field, typed as the field is, the statement written out below it as you fill it. A name FenecQL cannot write is refused rather than spliced into the text, and the `where` clause you type is held inside parentheses it cannot close, so it stays one condition among the page's. ## Query The Query tab is an editor for FenecQL. It runs exactly what you type, with your token: it can do what the token may, and nothing more. A query with its parameters, its rows, and the request id to find it in the logs. - **Run** with `Ctrl+Enter` or `⌘Enter`; a selection runs alone. - **Parameters** are a JSON list, bound to `$1`, `$2`… - **Several statements**, separated by `;`, run as one `/batch`: they land together or not at all. Give their parameters as a list each, `[[1], ["a"]]`. - **The answer** as rows or as JSON, with its status, its time, its `Fenec-Seq` for a write and its `X-Request-Id`. - **The plan** of a `get`: the Plan tab asks the server for `explain` of the same text with the same parameters. - **A refusal** shows the server's message and status, and for a batch which statement stopped it, which the editor selects. - **History and saved queries** stay in this browser. The token is never kept there. How the editor highlights and splits statements The colours are the docs' own rules: `site/build.py` writes the same patterns and keywords into the editor's highlighter, and `site/test_highlight.py` holds the two to the same output. The text is drawn over the textarea as text nodes, so the caret, selection, input methods and undo are the browser's. Nothing you type is rewritten: the editor only cuts the text at each `;` outside strings and comments, and a statement goes to `POST /query`, several to `POST /batch`, as any client sends them. ## Schema The Schema tab shows a collection as the statements that make it, and its fields. With the server's token you can add an index or a field, rename or drop a field, and drop the collection. An index on its way: the statement, and what the server's plan says of it. - **Every change is shown before it runs:** the exact statement, and what `POST /_schema/plan` says of the schema as it would be after it. - **A drop waits for its name.** Dropping a field or a collection runs only once you type its name. - **A scoped token reads the schema** of the collections it reads, and changes nothing. ```fenecql create index on orders (total) @sorted alter collection orders add field due timestamp @ttl(30d) alter collection orders rename field note to memo alter collection orders drop field memo drop collection scratch ``` How a schema change is planned The statements come from the server: `GET /_schema?as=fenecql` answers the description with its collections as FenecQL, written by the engine. The plan is asked of the collection as it would be after the change, and of no other, so it speaks of this change alone. It writes the statements an index or a field added would need, and refuses to guess a field that is gone: a drop or a rename? The statement says which. A change runs as a `/batch` under an `Idempotency-Key`, as the studio's other writes do. A name FenecQL cannot write is refused, and an index's options and a ttl's duration are checked before they reach the text. ## Live The Live tab follows a collection's rows as they change: new and rewritten rows are marked as they arrive, deleted ones struck through before they go. One customer's orders as they are written, each change listed. - **A `where` clause** picks the rows to follow. - **Pause** holds the changes back and counts them; resume applies them in order. - **A scoped token sees its own rows:** the server holds the stream to them, deletions included. How the live view is bounded The view is a subscription, `GET //changes` with your filter as its shape: first every row it selects, then each write. So a shape of more than 10 000 rows is refused before it opens: narrow it with the `where` clause. Under a scoped token the server ANDs the token's filter into the shape and keeps the ids it sent, so a row the token may not read never reaches the page, nor does the deletion of one. ## Admin The Admin tab is shown to the server's own token. It reads the statements by shape, the server's counts and, on a router, its tenants. The slowest shapes, and the server's numbers. - **Statements:** the slowest and the most run shapes, from `/_stats/statements`. Pick one to open it in the editor. - **Server:** reads, writes, errors and their latency, the file and what a compact would give back, memory and compactions, from `/_metrics`, read again every 5 seconds. - **On a router:** each tenant with its node, state and size on disk, from `GET /_shard/tenants?bytes`. When the Admin tab appears The tab appears when `/_whoami` says the token is the server's, or the server asks for none; the server decides again on every request. Through a router the tenants' list takes the router's `--token`: start the router with the nodes' data token and one token reads both. ## In your browser The site's [Playground](https://fenecdb.com/playground) is this studio — the same files the server embeds — over a database in your browser tab rather than a server's: fenecdb compiled to WebAssembly, on a worker of the tab, seeded with products, orders and a week of events. Nothing is sent anywhere. Rows, the query editor, the schema and the live rows work there as they do here; the query editor lists examples to run. | On a server | In your browser | | A token pasted in, its subject and expiry in the top bar | **Running in your browser.** in its place, and nothing to sign in to | | The tenant picker on a node or a router | none: one database | | The admin view, the file's sizes in the sidebar | none: those are a server's numbers | | Writes through `/batch` with an `Idempotency-Key` | the same statements, one block each batch | | Live rows through a subscription | a live query over the module's change ring | | Kept in the server's file | kept nowhere unless asked: **Keep it in this browser** stores it in IndexedDB, and **Reset data** puts every collection back | How a page in the browser answers like a server Every view reads through one surface: `run`, `batch` and `subscribe` as `FenecHttp` has them, and `request` for an answer shown whole — the editor's status and change, the schema and its plan. Over a server that surface is HTTP (`studio/connect.js`); in the page it is the browser module in a dedicated worker (`studio/local.js`, `worker.js`), which answers each request as a server would: the same rows, the status a server refuses a statement with, the change a write leaves the database at, the schema as the engine describes it (`Fenec.describe`) and a batch as one block (`Fenec.batch`). `studio/test/transport.test.mjs` sends the same statements to a real `fenec-server` and to the module and holds every answer equal. A server's first load holds none of it: the page's own modules load only where a page asks for them. ## Keys | `/` | the `where` clause | | `Alt+1`, `Alt+2` | the collections, the view | | arrows, `Page Up`, `Page Down` | move between cells | | `Enter`, `F2` | edit the cell | | `Space` | the whole row | | `C` | copy the row as JSON | | `S` | sort by the column, where its index orders it | | `N`, `Delete` | a new row; delete the row, once confirmed | | `R`, `?` | read the rows again; every key | | `1` to `5` | rows, query, schema, live, admin (`1` to `4` in your browser) | | `Ctrl+Enter`, `⌘Enter` | run the query, in the editor | ## Tenants On a tenant node or a router, the top bar picks the tenant: those the token's tenant claim names, or the node's or router's list where the token may read it. Otherwise type the tenant when you connect. ```bash fenec-server --dir tenants --http 127.0.0.1:8081 --admin-token "$ADMIN" --studio fenec-shard --listen 127.0.0.1:8090 --directory shard.fenec --studio ``` Tenants, tokens and origins A token is bound to the tenants it names on a node, so the picker offers nothing it would be refused. Through a router the studio is on the router's address and every tenant is that same origin; a page served by a node would be another origin to the router. `--studio-connect ` lets the pages a server serves send requests to one more origin, which its content security policy names. On a phone, light and dark: the collections slide in from the side. ## In an app An app that shows the studio in a window of its own — a desktop app over the server it started for a file — hands the page its session rather than having a token pasted. Before the page's modules run, from a preload script, it defines `window.fenecHost` — on the server's origin alone: ```js // The origin of the server the app started, or the one it connected to. const ORIGIN = 'http://127.0.0.1:54321'; // A preload runs in every page the window loads: a redirect's, a sign-in // page's. Any other origin gets nothing. if (location.origin === ORIGIN) { Object.defineProperty(window, 'fenecHost', { value: Object.freeze({ session: Object.freeze({ server: ORIGIN, token, tenant: null }), }), }); } ``` **Keeping the token from other origins is the app's job.** A page of another origin that finds `window.fenecHost` reads the token; the studio cannot hide it from one. Compare the origin as above, and keep the window on the server's origin. **The page's own world.** A preload that runs in an isolated world defines `fenecHost` where the page never sees it. Under Electron's default context isolation, expose it through the bridge, and check in the page that what arrives passes `Object.isFrozen`, which the studio asks of the session: ```js const { contextBridge } = require('electron'); if (location.origin === ORIGIN) { contextBridge.exposeInMainWorld('fenecHost', { session: { server: ORIGIN, token, tenant: null }, }); } ``` - **The page's own server.** The studio takes the session only when it is frozen and names the origin that served the page; otherwise it says the app handed it none, and does not send the token. - **Never stored.** The token is not written to session storage: a reload takes the session from the app again. - **The app holds the connection.** There is no sign-in form and no Sign out. A token the server refuses is said as the app's connection refused, to be made again from the app. The token is sent as a pasted one is, so the page has no more authority than the token. Start the server with [`--exit-on-stdin-close`](https://fenecdb.com/docs/server#flags) and a pipe on its stdin, and it stops cleanly however the app ends. ## Desktop app (beta) On a Mac with Apple silicon the studio is also an app: open a database file, make a new one, or connect to a server, with no server of your own to start. It is not published for download yet; build it from source. ```bash make studio-desktop open "integrations/studio-desktop/build/stable-macos-arm64/fenec studio.app" ``` - **A file.** File, Open… (or New Database…, or a double-click in Finder). The app starts a `fenec-server` it carries, on the file, and opens a window on that server's own `/_studio/`, so the window does what the studio does in a browser. Closing the window stops the server once it has written its checkpoint. - **A saved server.** A name, an address, a token and, for a tenant node, a tenant. The token is kept in the Keychain, never in the app's own files, and is sent only to the address it was saved for. The window is on that server's studio, so there is no CORS to set up. The server must run with `--studio`. - **A message, not a blank window.** A file another program has open, a server that stopped, one that cannot be reached, one with no studio and a token it refuses each say so in a window of the app's own, with the way forward. - **macOS only for now,** Apple silicon, and not yet signed. An app you build opens as it is; an unsigned one you downloaded needs `xattr -cr "fenec studio.app"` first. How the token is kept, and what the build needs A token is never in a URL or on a command line: a file's server is given a random one in its environment, and the window hands it to the studio as [`window.fenecHost`](#host), frozen and never stored, only on that server's origin, before any of the page's code runs. A server the app started does not outlive it: its stdin is a pipe the app holds, and the server stops when it ends, `kill -9` of the app included. A studio window cannot call into the app (it has an event bridge that carries a message one way, and nothing else) and cannot leave its server's origin: a link to another site opens in your browser. The Keychain keeps the token from other users and from the pages, not from another program of yours: that runs as you, as the app does. The build needs Node, npm and Rust, and fetches its build tool into the project's own directory: nothing is installed globally. The directory's [README](https://github.com/fenecdb/fenecdb/tree/main/integrations/studio-desktop) has the build, the checks, the failures and what each says, the secrets that sign it and what a first signed build still has to be checked for. ## Security The studio is a new surface, not a new authority. Treat it as you would the server's own port: keep it on a private network, or behind your own sign-in and TLS. | authority | none of its own: every statement carries the pasted token, so a scoped token sees its rows alone — in the grid, the counts and the facets | | the token | session storage of one tab, or in an app the session it hands over, stored nowhere; sent only as `Authorization: Bearer` | | headers | `Content-Security-Policy` with `default-src 'self'`, no inline script, `connect-src 'self'` and the `--studio-connect` origin; `X-Frame-Options: DENY`; `nosniff`; no referrer | | values | shown as text, never as markup; sent as parameters, never spliced | | the editor | runs what you type, as typed, with your token: no more authority than the token | | schema changes | shown as their statement and the server's plan before they run; a drop runs once its name is typed | | this browser | keeps the editor's history and saved queries, and the theme; never the token | | caching | the page is never kept; its modules are asked about again each load (`ETag`, 304) | | off | without `--studio`, 404 | --- Source: https://fenecdb.com/docs/replication # Replication Keep read replicas of a server, promote one when the primary is gone, and back up every write so you can restore to any moment. ## Start a replica Give the primary a replication token, then start a second `fenec-server` on its own file pointed at the primary. The replica serves reads; when the primary is gone, promote it to take writes. ```bash # the primary: replicas are fed over its HTTP listener fenec-server --file primary.fenec --http 0.0.0.0:8080 --http-token "$HTTP" \ --replication-token "$REPL" --sync always # a replica, over a file of its own fenec-server --file replica.fenec --http 0.0.0.0:8081 --http-token "$HTTP" \ --replication-token "$REPL" --replica-of http://primary:8080 # the primary is gone: the replica takes writes from here on curl -X POST replica:8081/_replication/promote -H "Authorization: Bearer $REPL" # {"promoted":true,"seq":41873,"history":"5f0e2c9a81b44d17"} ``` - **Every read works on a replica,** subscriptions included. - **Writes are refused** until it is promoted, by hand. - **A failover can lose** only what the primary's `--sync` had not yet put on disk. What a replica is and answers A primary sends its replicas the writes that have reached its disk, and each applies them to a file of its own and serves reads from it. A replica is a whole `fenec-server`: every read works on it, subscriptions included, from its own copy in memory, and a write gets 403. `GET /_replication` says which role a server has, so a client or a load balancer can tell a replica from the primary. ## What is sent A replica is sent the primary's writes in order and applies each one to its own file. Only writes already on the primary's disk are sent. - **A restarted replica** asks only for what it missed. - **A replica far behind** (past 64 MiB of writes) is sent a whole copy of the database instead. - **`--sync off` is refused** on a server with a replication token. How records are sent and caught up A fenecdb file is already a log: an image, then every write appended as a record. A replica is sent those records, whole and in order, and applies each the way the write path made it — the document, and the upkeep of every index on it — and appends it to its own file. Its file therefore ends as the primary's does, and a replica stopped and started again reopens where it was and asks only for what came after. The primary keeps the last 64 MiB of writes for this (`--replication-buffer`); a replica further behind than that is sent an image of the whole database instead, and follows write by write from there. Only writes on the primary's disk are sent. A write enters the feed when it is appended and leaves it once an fsync has covered it, so a primary that crashes comes back holding every write any replica was sent, and a replica never has to be told to forget one. The price is latency, set by the primary's `--sync`: under `always` a write goes out right after its own fsync, under `250` up to 250 ms later. Under `off` nothing is on disk before shutdown, so a server with a replication token refuses to start with it. ## Positions and histories A replica's position is a change number that means the same on the primary and on every replica. After a promotion the history forks, and a replica is only continued from a point the new primary also passed through; otherwise it is sent a fresh copy. | A replica that… | is sent | | reconnects to a primary that restarted, even after a crash | the writes it missed | | was stopped and started again over its file | the writes it missed | | is pointed at a promoted replica, holding nothing past the fork | the writes it missed, and the new history | | is pointed at a promoted replica, holding writes past the fork — the old primary, say | an image | | is further behind than the primary keeps writes for | an image | | starts empty | the writes from the first, or an image | Positions, histories and forks Every write moves the change counter by one, on the primary and on each replica alike, so a replica's position is its own counter and a subscriber's cursor means the same change on either. A block — the writes of one statement, a `/batch` — is one record numbered by its last write, and is sent and applied whole: a replica never stands inside one. A replica of a replica is fed exactly as its primary was. Numbers alone stop being enough once a history forks. A replica promoted after its primary died writes its own change 1 001; the old primary, or a second replica that had got further along, may hold a different 1 001. Streaming the new primary's 1 002 on top of that would go unnoticed. So each fork gets a fresh id and remembers the change where it left its parent, as PostgreSQL numbers its timelines, and a replica is continued only from a position the primary's own history passed through, as the table above shows. The history lives in the file ([record kind 8](https://fenecdb.com/docs/file-format)), and the first time a primary serves replicas it draws an id of its own — the root history is every database's, so it names none. ## Reading your own writes A replica can be a moment behind, so a read sent right after a write may miss it. Send the number a write's answer carries in `Fenec-Seq` as `Fenec-After`, and the replica waits until it has that write. ```bash curl -i -X POST http://primary:8080/query -d '{"query":"put carts {user: 7}"}' # Fenec-Seq: 1042 curl -H 'Fenec-After: 1042' -X POST http://replica:8080/query \ -d '{"query":"get carts where user = 7"}' ``` - **It waits 5 seconds** unless `Fenec-Wait` says otherwise (in milliseconds, 30 s at most), then answers 504. - **A subscription and `/_changes` wait too,** before their head: a stream sent with the header is seeded with the write, and a change-stream page's `Fenec-Seq` is never behind it. - **Across a failover, send `Fenec-History` too:** the answer names the history its change is of, which a promotion forks. A replica left on the old branch holds a change of that number of its own, and answers no read sent after the new one. - **Its token comes first:** a request the server refuses (401, or 403 for another tenant's token) is answered at once, not after the wait, and learns nothing of where the replica stands. - **A read marked `Fenec-Read: replica` names where it read:** the answer carries the copy's `Fenec-Seq` and `Fenec-History`, so a read sent after it never reads older ([Read replicas](https://fenecdb.com/docs/sharding-read-replicas#read-replicas)). A read nobody marked names nothing. - **The Python client** keeps the number for you: `client.seq`, sent with `query(..., after=...)`. How Fenec-Seq and Fenec-After work A replica is behind its primary by the time a write takes to reach it — 0.04 ms at the median under `--sync always`, up to the sync interval otherwise — so a client that writes on the primary and reads on a replica can read from before its own write. Over HTTP a write's answer names the change it left the database at, `Fenec-Seq`; a request sent to a replica with `Fenec-After: ` is served once the replica holds it, and answered 504 with where the replica stands if it does not within the wait — never from before the write. A change number names a write within one history. A promotion forks the history where the replica promoted stood: a write the old primary answered and the replica never got is lost, the new primary's next write takes its number, and a replica still following the old primary holds a change of that number — the lost write. So an answer of a database that has had a replica also names its history, `Fenec-History: -`, the term one more at each promotion; a read sent with it beside `Fenec-After` is served once the replica holds that change of that history — a replica on the old branch waits until it follows the new primary and takes its image. A primary asked for a position its history does not hold — the write lost — answers 504 at once, with its own `seq` and `history`, which a client takes as where it stands from then — over the position it sent that read after alone, and never an earlier term of the same lineage, which only a fenced old primary names. A history's lineage is the top half of its id: every promotion and restore keeps it, and a database made anew — a tenant deleted and made again under its name, starting again at term 1 — has another, whose position a client takes, its term lower or not. The wait is 5 seconds unless `Fenec-Wait` gives it in milliseconds, 30 seconds at most, and a replica waits on the same `Condvar` its subscriptions do, so nothing polls. On a tenant node a wait takes one of the tenant's streams, but only once it must wait: a read after a write the node holds already takes none, as a tenant whose streams its own subscriptions fill still reads its writes. Writing the header costs the server nothing measurable; a client that parses every header, as Python's `http.client` does, reads one more. ## Promotion Promote a replica by hand: `POST /_replication/promote` on its HTTP listener, or restart it over its file with `--promote` in place of `--replica-of`. Keeping the old primary from taking more writes is up to you. What promotion forks, and the old primary's part Either way forks the history where the replica stands and turns writes on. A replica's file does not open any other way — started with neither flag, `fenec-server` refuses and says which to give — because a replica taking writes without a fork would continue its primary's history with writes the primary never made. Making sure the old primary takes no more writes is the operator's part, as it is with PostgreSQL: nothing here fences it. Brought back, it rejoins under the new primary with `--replica-of`, and is sent an image if it wrote past the fork. ## What a failover loses With `--sync always` on the primary, no acknowledged write was lost in ten failovers. With the default `--sync 250`, a failover loses up to the last 250 ms of writes. | primary's `--sync` | acknowledged writes/s | lost per failover | | `always` | 284 | 0 in all ten | | `250` | 53 565 | 9 700–10 788: the last ≤ 201 ms | Method, and what a failover loses How it was measured: a primary killed with `SIGKILL` while one client writes to it as fast as it answers, the replica promoted half a second later, ten times per policy; counted are the writes the client was told succeeded that the promoted replica does not hold (`make replica-bench`, macOS, loopback). Under `250` the loss is the policy's own: those writes were answered before any fsync and were not on the primary's disk either. Under `always` a write is answered after its fsync and once the stream to each replica has written it into its socket, which the kernel delivers after the process that wrote it is killed. Sent after the answer, as it was before, a write answered in the fraction of a millisecond before it was sent was lost: none of these ten failovers caught one, while a node of the SaaS example killed under eight writers lost one in 3 of 60 runs, and none in 120 since. A stream that has not sent a write within a second is not waited for until it catches up again, so a stalled replica costs the writes one wait. A machine that dies takes its sockets with it: this holds for a process killed, not for a power loss. No failover left the replica holding a write the client had not been answered for. ## What it costs A write is readable on a replica 0.04 ms after the primary answered it under `--sync always`, and within the sync interval otherwise. A replica keeps up with a bulk load. | primary's `--sync` | p50 | p95 | p99 | max | | `always`, writes back to back | 0.04 ms | 0.06 ms | 0.09 ms | 2.77 ms | | `250`, 250 writes a second | 148 ms | 262 ms | 273 ms | 282 ms | Method, and the lag and bulk-load figures How it was measured: primary and replica in one process over loopback, each over its own file; lag is from a write's answer on the primary to the write being readable on the replica, over 1 000 writes. A replica keeps up with a bulk load: 100 000 documents of 128 dimensions with an HNSW index, which the primary wrote in 23.4 s in statements of 1 000, were applied from the stream in 19.7 s — 5 088 writes a second, the graph built as they arrive. A new replica of that database sent it as an image — 58.3 MB, the graph in it — answered from it 0.17 s after it connected. The primary keeps the writes in memory up to its buffer, and copies the image it sends; a replica holds the database in memory as a primary does. The replica's HNSW graph is built from the same vectors in the same order, but not in the same batches, so an approximate `near` can rank a close call the other way on the two; everything else answers identically. ## Backups and restoring to a moment An archive is the replication stream written to a directory: base images and every write after them, timestamped. From it, `fenec restore` rebuilds the database as it stood at any moment or change. ```bash # the running database, taken whole: a file that opens as a database of its own fenec backup http://primary:8080 nightly.fenec --token "$REPL" # every write that reaches the primary's disk, until interrupted; an image of # its own every hour, and what no restore to the last week needs let go of fenec archive http://primary:8080 /backups/archive --token "$REPL" --image-every 1h --keep 7d # what the archive can restore, read as a restore would; exits 1 if it cannot fenec verify /backups/archive # the database as it stood just before the mistake fenec restore /backups/archive before.fenec --to 2026-09-22T10:14:59Z fenec restore /backups/archive before.fenec --to-change 41873 ``` - **`--to`** stops at a moment; **`--to-change`** after a change number. - **`--keep`** (or `fenec prune`) deletes what no restore within that window needs. - **`fenec verify`** says from when to when the archive can restore. How a restore to a moment works The same stream written to a directory instead of applied is an archive: base images, and every write after them with the time the primary appended it. The `fenec` command line writes and reads one. A restore takes the latest image at or before the point asked for and appends the archived writes after it up to there — a fenecdb file is an image and the writes after it, so that is the whole restore — then opens the result to check it, forks its history and checkpoints it. `--to` keeps every write the primary appended at or before that moment and stops at the first one after it; `--to-change` stops after that change. A statement writes each document as a change of its own, so a `del` of ten documents is ten changes. The archive is fed as a replica is: only writes that were on the primary's disk, so none a crash took back, and a `compact` on the primary loses it nothing — the writes were archived before a compact folded them into an image. An archiver that falls further behind than the primary keeps writes for takes a new image and goes on from there; the archive then holds no write between the two images, and a restore to a point in that gap says so rather than inventing one. Restoring the 100 000 × 128 load above from an image and its 100 000 writes took 19.9 s, most of it building the graph for them, and the file then opened in 63 ms; to the load's middle, 7.8 s. A base image a day keeps a restore to a day of writes. ```text image--