Import
One command turns a SQLite file or a PostgreSQL table into a fenecdb collection, its fields typed and its indexes built, and can keep it following the table's changes.
Import a table
Name the source, the table and the collection to make. The schema is worked out from the table's columns, and each index you name is built once the rows are in.
fenec import data.sqlite --table docs --into articles --index "category@hash"
fenec import postgres://user@host:5432/database --table docs \
--into articles --index "category@hash"$ fenec import data.sqlite --table docs --into articles --index "category@hash"
source data.sqlite -> docs
target articles.fenec -> articles
3 records, articles.fenec (12.82ms)
index category @hashWhat the import derives and writes
The import derives the schema, the table's integer key becoming each
document's id, writes the rows in batches and builds the indexes after the
last, rather than keep them up a row at a time. The collection goes into
<into>.fenec unless --file names another. A
column of embeddings comes across too (Vector
columns). Every option is in fenec import --help.
Look before you write
--dry-run prints the schema it would make, and any warnings,
and writes nothing.
$ fenec import data.sqlite --table docs --into articles --index "category@hash" --dry-run
source data.sqlite -> docs
target articles.fenec -> articles
create collection articles (
title text <- text
category text <- text
score int <- int
published timestamp <- datetime
)
id <- id (INTEGER PRIMARY KEY)
create index on articles (category) @hash
nothing was written (--dry-run); use --count for the row count--countalso prints how many rows will be read.
Why --count is a separate flag
--count is a separate flag because counting means a full scan:
SQLite keeps the row count nowhere and count(*) in PostgreSQL
reads the table from the start. Silently reading everything twice would be a
bad default.
Sources
A SQLite file is read directly, and a PostgreSQL server is read over the network. Nothing else needs to be installed for either.
- SQLite: no
sqlite3library or binary needed. - PostgreSQL: no
psqlorpg_dumpneeded; pgvector's columns are recognised.
How each source is read
SQLite. The file format is read directly — neither the
sqlite3 library nor the binary is needed. INTEGER PRIMARY
KEY becomes the document id, --vector field:N turns
little-endian f32 BLOBs into vector<N>, and
untyped columns are resolved from the first 1000 rows.
PostgreSQL. It connects to the live server and streams
COPY ... TO STDOUT — no psql, no
pg_dump. Authentication is SCRAM-SHA-256, hand-written in
fenec-wire's client over fenec-http's own crypto. pgvector's
vector, halfvec and sparsevec columns
are recognised along with their dimensions.
Type mapping
Each source type becomes the nearest fenecdb type. Types with no exact
match are refused until you say what to do with --cast.
| Source | fenecdb |
|---|---|
integer bigint int2/4/8 | int |
real double float4/8 | float |
text varchar char clob | text |
blob bytea | bytes |
boolean | bool |
date datetime timestamp(tz) | timestamp |
int[] float[] text[] | [int] [float] [text] |
vector(N) halfvec(N) | vector<N> vector<N, f16> |
sparsevec(N) | sparse<N>, read in the same text form |
geometry(Point, 4326) geography(Point) point | geo, [lon, lat]; another SRID or shape refused (Points) |
uuid | text |
citext, a SQLite column COLLATE NOCASE | text nocase, compared by its case fold (Case-insensitive text) — every script's, where SQLite's NOCASE folds ASCII alone |
numeric decimal | no equivalent — --cast required |
json jsonb | json, its numbers as written; a path reads into it (Objects and paths) |
Types with no equivalent, and --cast
Types without an equivalent are not silently rounded: say what you want with
--cast <field>=<type>. On PostgreSQL,
--cast price=text preserves a decimal exactly. In SQLite a
decimal is already stored as an int or a
float at the source, so the exact value is gone before the import
starts.
Filtering
--where keeps only the rows that match a FenecQL expression,
written with the new collection's field names. It works the same on both
sources.
fenec import data.sqlite --table docs --into articles \
--where 'category = "book" and score >= 10 and tags has "rust"'--source-where(PostgreSQL only) filters on the server in SQL, so skipped rows never travel.--limitcounts rows that pass the filter.
Why --where and --source-where are two flags
--where is a FenecQL expression and behaves identically
on both sources: it is applied after the row becomes a document and before it
is written. The field names are the target schema's — the same expression you
would later write with get.
On PostgreSQL there is also --source-where, which goes into the
SELECT inside the COPY so non-matching rows never
travel over the wire. That one is SQL, not FenecQL. They are separate
flags on purpose: one is a portable expression, the other a win pushed down to
the source. Both can be used together.
fenec import postgres://... --table docs --into articles \
--source-where "year >= 2024" --where 'tags has "rust"'With a local --where in place, --limit is not
pushed to the source: cutting on the server first and filtering locally
afterwards would yield fewer rows than asked for. --limit always
counts rows that pass the filter.
Following changes
With --follow the import keeps running after the copy,
applying the PostgreSQL table's changes as they commit. Run the same command
again and it picks up where it stopped.
$ fenec import postgres://app@db:5432/app --table docs --into articles \
--index "category@hash" --follow
source app@db:5432/app -> docs
target articles.fenec -> articles
follow slot fenec_articles (created), publication fenec_articles (created)
note the slot keeps the server's WAL until this follower confirms it; to stop following for good: select pg_drop_replication_slot('fenec_articles')
copying there is none yet
100000 records, articles.fenec (339.37ms)
following docs: ctrl-c stops, and the same command resumes
42 transactions, 57 changes, at 0/2BEA458- The server needs
wal_level = logicaland the table an integer key; nothing is installed on it. - Nothing is lost or applied twice when the connection drops or the follower is killed.
- Drop the slot when you stop for good, or PostgreSQL keeps its log for it.
How --follow reads, resumes and confirms
With --follow the import does not end at the copy: it keeps
applying the table's inserts, updates, deletes and truncates as they commit,
until it is interrupted. Run the same command again and it picks up where it
stopped, without a second copy.
It reads a logical replication slot through PostgreSQL's own
pgoutput, so nothing is installed on the server. The
publication and the slot are made when missing (--publication,
--slot; both default to fenec_<into>). The
server needs wal_level = logical, and the table needs an integer
key that becomes the document id, since a changed row has to be found again.
It must also be in the replica identity: the primary key, or
replica identity full.
- A row arrives as the copy would have written it. The stream goes
through the same type mapping,
--cast,--vectorand--whereas the copy. A row that stops passing--whereis deleted from the collection. - Nothing is confirmed before it is on disk. The slot only moves past a transaction once an fsync has covered it. A broken connection or a killed follower starts again from there, and the writes it replays are puts and deletes by id, so applying them twice changes nothing.
- An untouched vector is not resent. A vector of 768 floats is 3 KB, past PostgreSQL's TOAST threshold, so an update of any other column arrives without it; the follower takes it from the collection.
- A copy is resumed only when it is whole. A small
_followcollection in the file records which collection follows which slot and whether its copy finished. A copy cut short, or one whose slot was dropped in the meantime, is made again rather than streamed on top of, and a collection the follower did not copy is never replaced.
Measured against the Makefile's server, 10 000 rows with a
vector(384), the mirror a file (make
follow-bench):
| vector(384) | vector(768), TOASTed | |
|---|---|---|
| commit to visible, one row a transaction | p50 0.16 ms, p99 1.56 ms | p50 0.16 ms, p99 1.57 ms |
| one transaction updating every row | 40 400 rows/s | 52 600 rows/s |
| 2 000 one-row transactions from one client | visible 0.3 ms after the last | 0.2 ms |
| a connection the server cut | 553 ms | 536 ms |
The slot keeps the server's log until the follower confirms it,
for as long as it exists. To stop following for good, drop it:
select pg_drop_replication_slot('fenec_articles'). PostgreSQL
13 and later can cap what a forgotten slot holds with
max_slot_wal_keep_size.
The follower is the file's only writer, as every fenecdb file has exactly
one: stop it to open the mirror elsewhere, or serve the mirror from the
server that follows (below). A column added to the
table after the copy is reported and left out. A column the collection needs
that is dropped from the table stops the follower.
--source-where and --limit do not combine with it:
the first narrows the copy but not the stream, and a mirror keeps every
row.
Serving the mirror as it follows
fenec-server --follow runs the follower inside the server, so the
table is served over HTTP and subscriptions while it follows.
$ fenec-server --file articles.fenec --http 0.0.0.0:8080 --http-token "$TOKEN" \
--follow postgres://app@db:5432/app --follow-table docs --follow-into articles \
--follow-index "category@hash"
opened: articles.fenec
following: docs
fenec-server 0.1.12: articles.fenec [sync=250 ms]
fenec-http 0.1.12 listening on: http://0.0.0.0:8080 [token]
follow: docs: slot fenec_articles (created), publication fenec_articles (created)
follow: the slot keeps the server's WAL until this server confirms it; to stop following for good: select pg_drop_replication_slot('fenec_articles')
follow: copying docs: there is none yet
follow: 2000 rows of docs copied
follow: docs copied, 100000 rows in 160.10ms
follow: docs: streaming its changes- Import options take a
--follow-prefix:--follow-index,--follow-castand so on. - The mirrored collection is read-only to clients (403); the rest of the file takes writes.
Running the follower inside the server
A second process over the follower's file corrupts it, so serving the
mirror took a copy of it and a process of its own. fenec-server
--follow runs the follower inside the server instead, over the file
the server serves: the table is served over HTTP and subscriptions as it
follows, with no second copy.
Every option of fenec import that says how a row becomes a
document — --index, --vector, --cast,
--id, --where, --batch — is taken as
--follow-index and so on, as are --follow-slot and
--follow-publication. The collection takes no write but the
follower's: a client's is refused with 403, since the next
change from PostgreSQL would write over it. The rest of the file takes
writes as ever. A subscriber of the collection
(GET /articles/changes) hears each change as the follower
applies it, and so does a replica of the server.
The follower stops with the server, its last changes on disk and confirmed, before the shutdown's checkpoint. A server killed outright starts the stream again from what it last confirmed; the changes it applies again are puts and deletes by id, so nothing is lost and nothing comes twice. An error the follower cannot wait out ends the server rather than leave it answering from a mirror that no longer moves.
Against the Makefile's server, 10 000 rows with a vector(384)
under an HNSW index, a subscriber reading /changes over HTTP
(make mirror-bench):
| commit to the subscriber | p50 | p99 |
|---|---|---|
| an update of a title | 0.09 ms | 4.00 ms |
| a row inserted with its vector, put into the graph first | 0.19 ms | 3.34 ms |
Commit to visible in the file, with no server between
(make follow-bench), was 0.16 ms at the median on the same
machine. Killed while the table took a row a transaction, the server was up
again in 75 ms and held every one of the table's 14 063 rows 370 ms after it
started. The follower adds 178 KB to fenec-server.
Vector columns
A column of embeddings becomes a vector field, and an
@hnsw index named with --index is built once every
row is in. pgvector's columns are recognised with their dimensions; a
SQLite BLOB of little-endian f32s is named with
--vector.
fenec import data.sqlite --table passages --into passages \
--vector embed:384 --index "embed@hnsw(cosine)"
fenec import postgres://user@host:5432/database --table passages \
--into passages --index "embed@hnsw(cosine)"$ fenec import data.sqlite --table passages --into passages --vector embed:384 --dry-run
source data.sqlite -> passages
target passages.fenec -> passages
create collection passages (
text text <- text
source text <- text
embed vector<384> <- blob
)
id <- id (INTEGER PRIMARY KEY)
nothing was written (--dry-run); use --count for the row countHow vector columns are imported
The graph is built after the copy, over every vector at once and on
every core, which is the order a bulk load wants (Bulk load first, index
after). pgvector's vector, halfvec and
sparsevec columns become vector<N>,
vector<N, f16> and sparse<N> with
nothing named; --follow keeps them in step as any column, an
untouched vector taken from the collection rather than resent (Following changes).
Speed and testing
100 000 rows × 3 dimensions, index build included, take about 3.1 s from either source.
make import-test # the PostgreSQL arm and --follow — needs Docker
cargo test -p fenec-import --test all -- --ignored pg:: follow::What the import adds to the binary
Import is a default feature of the fenec binary and accounts
for 275 KB of it. --no-default-features removes it, and
fenec backup, archive, restore and
fenec mcp with it (502 KB); with the cli profile as
well, the binary drops from 3013 KB to 1880 KB (make small).