fenecdb

HTTP endpoint

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.

fenec-pg --file data.fenec --http 127.0.0.1:8080
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}

Not a separate binary. The HTTP endpoint is a second listener in the same process as fenec-pg. 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.

Routes

GET    /                        version and collection list
GET    /collections             schemas
GET    /<name>?<filter>         rows
GET    /<name>?<filter>&count   number of matching rows
POST   /<name>                  body: {...} or [{...}]
PATCH  /<name>?<filter>         body: {...}
DELETE /<name>?<filter>
PATCH  /<name>/all              no filter — deliberately explicit
DELETE /<name>/all
POST   /<name>/near             body: {"vector":[...], "limit":10}
GET    /<name>/changes?since=N  subscription (SSE): shape plus incremental diff
POST   /query                   body: {"query":"<FenecQL>","params":[...]}
POST   /batch                   one query body per line (NDJSON)

Filters

Operatorseq neq lt lte gt gte like has in is
Negationthe not. prefix — ?summary=not.is.null, ?year=not.eq.1999
Shorthandan unrecognised prefix makes the whole value an equality: ?year=2024
List?year=in.(1999,2023)
Clausesselect order limit offset count where
Ordering?order=year.desc,title.asc
Free expression?where=year >= 2024 and tags has "rust" — full FenecQL

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.

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.

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}]

If the collection has a single vector field, field can be left out. Filters in the query string are anded with the where in the body.

Raw FenecQL

When the REST shape does not fit, send the language itself. Parameters stay parameters.

curl -X POST http://127.0.0.1:8080/query \
  -H 'content-type: application/json' \
  -d '{"query":"get articles where year >= $1 limit 10","params":[2024]}'

Batches

POST /batch takes one query body per line as NDJSON. The statements run in order under a single write lock and stop at the first error. This is not a transaction — fenecdb has none — and it does not pretend to be one. What it buys is a single round trip and no other writer slipping in between.

{"error":"collection `tasks` has no field `nofield`","completed":1}

A failed batch reports how many statements were applied, 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.

Subscriptions

GET /<name>/changes is a server-sent events stream: a seed carrying the current shape, then incremental diffs. No driver needed.

curl -N 'http://127.0.0.1:8080/articles/changes?year=gte.2024'
# event: seed
# data: {"seq":12,"rows":[...]}

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 (--http-max-streams, default 64). Sharing one ceiling would let 100 subscribers shut the server to plain requests. The client-side layer built on this stream is Sync.

Security

--http-token <value>every request needs Authorization: Bearer <value>, compared in constant time
--http-cors <origin>sets Access-Control-Allow-Origin; without it no CORS header is sent at all
--http-read-onlywrite endpoints return 403; fenec-pg's own path is unaffected
non-loopback addressrefuses to bind without a token, unless --insecure is given

There is no TLS, the same rule as fenec-pg: 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-pg (--max-connections, --idle-timeout); 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) cannot be filtered over HTTP; the FenecQL and fenec-pg paths are unaffected.

A shape is not a security boundary. Which ids changed is visible to a subscriber even when the rows are not. Hiding rows needs a separate endpoint, or --http-read-only plus a token.

fenec-pg --file data.fenec --http 127.0.0.1:8080 \
         --http-cors 'http://localhost:8787' \
         --http-token "$TOKEN" \
         --http-read-only