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; tokens and policies are on HTTP security and scoped access.

Routes

GET    /                        version and collection list
GET    /collections             schemas
GET    /<name>?<filter>         rows
GET    /<name>?<filter>&count   number of matching rows
GET    /<name>?select=status,count(*),sum(total)&group=status&order=sum(total).desc
                                aggregates, whole or per group
GET    /<name>?<filter>&facet=brand%20top%205,color
                                rows, and each value's count beside them
POST   /<name>                  body: {...} or [{...}] -- an id that is taken: 409
POST   /<name>?returning=id,title   the rows made, ids and all (* every field), not their count
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)
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. 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. 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. 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.

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)
Pathsa 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)
Clausesselect 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)
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.

Send the vector in a POST body to /<name>/near, with an optional filter. Add "match" to mix in keyword search.

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 anded with the where in the body.

A vector in an answer, and a _score, is its f32s 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). 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.

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.

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, <name>.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=<name> 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.

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.

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: <ms> 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).

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), 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.

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.

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

StatusMeansWhat to do
400a statement that does not parse or run, a bad parameterfix the request
401no token, or a wrong onesend the right token
403outside 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-Readwrite where it is taken
404a collection, field or tenant that is not there
409an id or a @unique value taken, a collection that existsa lost race for a value
410a /_changes cursor the server no longer reachesstart again from the since it names
412a require not met, its batch put backread again, decide again
413a body past the ceiling, 64 MBsend less at once
422an Idempotency-Key sent with another requesta new key for a new request
429a tenant's quota of streams, held, requests, writes or write_bytescome back after retry_after_ms
500outcome 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'ssend 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)
500an 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 againrestart the server, then read the write back, or send it again with the same Idempotency-Key
503too 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 rancome back after Retry-After seconds
504a 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 standsask again, or ask the primary; take the primary's position as yours
507past --max-memory, or a tenant's quota of bytes or rows; the write put back wholedelete, then write again: waiting does not help
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, how it is set under Sharding: tenants and why the statuses are these under Sharding: 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 /<name>/changes is a server-sent events stream: first the rows matching your filter, then each change to them as it happens.

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).
  • 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 builds on this stream.
Why a subscription does not poll

GET /<name>/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.

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

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.

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/<tenant>/_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.

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": <where it stands>}: 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.