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); 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).
  • Indexes live in memory. The data stays in the mapped file; the indexes are restored or rebuilt on open (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).
  • No TLS. Put a TLS terminator in front of any port off the machine (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, a filter by another collection's rows is in (get …), a unique field is @unique, a field is added, dropped or renamed with alter collection, and nested objects are a json field.

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

LimitValueNote
near, match, fuse result (limit + offset)10 000 rowsa 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 reaches10 000the 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 takes2and 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 side10 000a query error when exceeded; never fewer than limit + offset
expression depth512 levelsparentheses and and/or chains count
lookup chain depth8 levelsa query error when exceeded; the parser, the check and the engine all recurse per level
fields of a lookup key8as 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 …) values100 000distinct 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 …) values1 000as 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 …) nesting4 levelsa query error when exceeded; each level runs a query before the one around it
values of one facet10 000a 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 ... ranges10 000a query error past 10 001 bounds; every range answers
json value nesting64 levelsobjects and lists inside one another; a write or a JSON text deeper is refused, never cut
path length64 keyspast its field (meta.a.b is two); a query error, since no value nests deeper
fields of an index over several fields8refused past it, and under 2: one field's index is its own @sorted or @unique
spans the in lists over an index's parts make256the elements of each, multiplied; past it the list is tested row by row, never cut
session stack (fenec-server)8 MiBvirtual; the deepest expression wants ~750 KiB in release, ~5 MiB in debug
segment size8 MiBsealed when full, a new one is opened
document payload, segment offset4 GiBthe location record is a u32
vector index node count232the node id is a u32; memory runs out first in practice
level-0 degree (m0 = 2m)65 535u16 counter, so m ≤ 32 767
bulk insert batch64–512 vectorsgraph/16, clamped to this range
parallel build threshold1024 vectorsbelow it, the serial path
gap in an externally given id4096a larger jump falls into a sparse map rather than a dense array
timestamp1 ms, i64±2.9×108 years; microseconds truncated, always UTC
bucket by a named zonefrom 2000IANA'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<N, f16>±65 504~3 decimal digits; outside it, values silently become inf or 0
geo point±180°, ±90°two f64s kept as written; past either range a write is refused, never wrapped
sparse<N> dimension109pgvector's; the weights are f32, finite, and a zero is dropped
change ring4096 entries--changes; how far behind a subscriber may fall, 24 bytes each
concurrent subscriptions64--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 rowsnone unless set--tenant-max-rows, its rows; its collections together, the server's own (_-named) left out; 507
a tenant's streamsa quarter of --max-connections unless set--tenant-max-streams, its streams: subscriptions, /_changes waits and Fenec-After waits together; 429
a tenant's held writesa 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 flightnone unless set--tenant-max-requests, its requests; 429
a tenant's rows written a secondnone 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 secondnone unless set--tenant-write-bytes-per-sec, its write_bytes; charged as each write lands, 429 while in debt
rows an MCP tool answers100--mcp-max-rows; past it the call is refused (413) with what to narrow, never cut (Agents and MCP)
bytes an MCP tool answers64K--mcp-max-bytes, the answer's JSON; refused past it (413)
MCP tool calls a second20--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), 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=) — 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 footprintpeak RSSratio
100 000 × 128128.2 MB172.9 MB74%
200 000 × 457.6 MB97.2 MB59%

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). 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, 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) 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<N> 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).
  • 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).
  • 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<N, f16> 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 <s>, 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).
  • 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 <db> "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).
  • 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.