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:8080curl '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
| 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) |
| Clauses | select 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.
Vector search
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-only | write endpoints return 403; fenec-pg's own path is unaffected |
| non-loopback address | refuses 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