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 @hash
What 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
  • --count also 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 sqlite3 library or binary needed.
  • PostgreSQL: no psql or pg_dump needed; 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.

Sourcefenecdb
integer bigint int2/4/8int
real double float4/8float
text varchar char clobtext
blob byteabytes
booleanbool
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) pointgeo, [lon, lat]; another SRID or shape refused (Points)
uuidtext
citext, a SQLite column COLLATE NOCASEtext nocase, compared by its case fold (Case-insensitive text) — every script's, where SQLite's NOCASE folds ASCII alone
numeric decimalno equivalent — --cast required
json jsonbjson, 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.
  • --limit counts 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 = logical and 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, --vector and --where as the copy. A row that stops passing --where is 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 _follow collection 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 transactionp50 0.16 ms, p99 1.56 msp50 0.16 ms, p99 1.57 ms
one transaction updating every row40 400 rows/s52 600 rows/s
2 000 one-row transactions from one clientvisible 0.3 ms after the last0.2 ms
a connection the server cut553 ms536 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-cast and 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 subscriberp50p99
an update of a title0.09 ms4.00 ms
a row inserted with its vector, put into the graph first0.19 ms3.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 count
How 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).