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,likeand ajoinon an indexed key work (From SQL); a join of any other condition does not.lookupcovers related rows (below). - One writer per file. Readers run beside it, and a
/batchholds 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
jsonwhere a shape is not fixed. Ajsonfield 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.
floatis binary floating point and unfit for money. The answer is anintin cents. - No UUID type.
text @hashis functionally enough. - Objects only in a
jsonfield. A typed field still refuses one, and a list of a type holds that type alone. Ajsonfield holds any value JSON does; a path filters, orders and is indexed (@hash,@unique,@sorted), but an aggregate andgroupread 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 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 | 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 …) 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 | 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 | 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<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> 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) |
| 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-memoryto 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. compactpeaks 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. Acheckpointis 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 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). 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, slowingneara little.
Graphs, compaction, batches, ordered indexes and buffered writes
- The graph is derived data and is written only during
snapshot,compactandcheckpoint— 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
fenecshell when leaving an interactive or piped session, andfenec-serveron the shutdown signal — both only if a vector index exists. A one-shotfenec file.fenec -c '...'does not. Neither doeskill -9or 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,nearmeasures each of them, and a thread beside the queries links them. At 100 000 × 768 never checkpointed,fenec-serveranswered its firstnear1.23 s after it started, against 67.7 s when the open linked them first, which now takes 18 s;neartakes 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 withnearat 1.56 ms meanwhile, where every vector took 19.1 s in the same run. compactrebuilds 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 acreate indexof it costs — 50 s at 100 000 × 768.- A server builds an index or compacts beside the database. A
create indexorcompactsent on its own tofenec-serverorPOST /querycopies 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_COMPACTturns 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 callscompact_when_duefrom a thread of its own or starts aCompactor; 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 beamnearwalks. 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.
@sortedkeeps about 20 bytes a row for a number, plus the text for atextfield, 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
syncorcheckpoint, the last writes can be lost. In embedded use callingsyncis 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
andchain of equalities and ranges reaches an index;!=,~and anything underorscan every row. orderreads every match unless it is over one@sortedfield with alimit.putdoes not check that a vector is finite.
Edge cases of sparse near, filters, order and collation
nearover asparse<N>field ranks only the documents sharing a dimension with the query, by dot product and exactly — it takes noef. A document with no dimension in common scores nothing and is not returned, as a document holding no word of amatchquery 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
andchain reaches an index — equality (=orin [..]) over a@hashfield or overid, and comparisons (<,<=,>,>=,=) over a@sortedfield.!=,~,has, a comparison on a field without@sorted, and anything under anoris a full scan: every matching row is decoded and evaluated.~is unranked substring matching; ranked retrieval ismatchover a@textfield, which does reach an index. - A
@hashequality answers as the scan does, a float's-0.0and0.0as one value. Inside a list or a vector a-0.0keeps 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.5on anintfield, or an integer past 253 against atimestamp, is tested row by row. A range wider than half the collection is scanned rather than read from the index. - An
inlist 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
nearreads 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. orderreads every match, unless it is over a@sortedfield and has alimit. Onlyoffset + limitrows 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, notlimit: over a million rowsorder created desc limit 20takes 26.8 ms, and 0.001 ms oncecreatedis@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 afloatfield holding aNaN, which has no place in an order, takes the sorting path. Withoutorder, a scan stops atoffset + limitmatches.- Two collations: Unicode's root and Turkish.
collate undorders text as ICU's root collation does, for every script, andcollate tras its Turkish one; another language's own order — Swedishå ä öafterz, 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 inwhereover 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). nocasefolds case, and only case. Unicode's full case folding (Turkish's incollate tr), with no normalisation:éas one code point and aseand a combining accent are two texts, as arerésuméandresume, and a full-width letter is not its ASCII one. It is a text field's alone, not a list's or ajsonfield's, and a field is madenocase, 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@sortedindex folds each row's key once, 8.5 → 16.2 ms (Case-insensitive text).nearandordercannot be used together;neardetermines 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. putdoes not check that a vector is finite. A NaN or infinite component enters the index and makes distances meaningless; the check exists only on thefenec importpath. In avector<N, f16>field an out-of-range value silently becomesinfor 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.openfails with aFenecErrorthat 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 withDate.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
/batchis 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
/batchlands its statements whole or not at all, and anIdempotency-Keymakes it safe to retry. Acompactcannot 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-connectionsrejects 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 itsRetry-After. A connection the process can make no thread for — at its thread limit, below the ceiling — is answered the same 503, withRetry-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
limitandoffset. - 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: chunkedis unsupported (411). The body is capped at 64 MiB and the header block at 64 KiB; above that, 413 or 431.- Pagination is
limitandoffset— no cursor, noRangeheader — 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 largeselectholds 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 /queryandfenec-serverare unaffected. - No
ETag, noLast-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,PATCHandDELETE. There is no WebSocket; for the same job it would demand framing, masking and ping/pong. - A subscription refuses
order,limit,offsetandcountwith 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
compactrewrites the file, which cannot be put back, so aPOST /batchholding one runs each statement on its own, stops at the first error and reports how many were applied. Every other batch — acreate, adropor acreate indexamong 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.
--followneedswal_level = logicaland an integer key.
What import does not read or follow
- One table becomes one collection. Multi-table migration is out of
scope; a
JOINon a key reads as alookupafterwards. fenec importis in the default build but is a separate feature. A binary built with--no-default-featureshas no such subcommand, and says so before exiting.--wheresees 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 ROWIDtables 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.
--followneedswal_level = logicaland 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.