JavaScript and TypeScript
Dependency-free ES modules run fenecdb in the page: open a database, query it, and keep it between visits — or query a server through the client alone. Their types come with them.
The shortest way in: install the package, open the module and query it. This is the script of Quickstart: in the browser, a Vite project that you can follow from an empty folder:
npm install @fenecdb/webimport { Fenec } from '@fenecdb/web';
import wasm from '@fenecdb/web/fenec.wasm?url'; // Vite hands over the module's URL
const db = await Fenec.open(wasm);
db.run('create collection notes (title text @text, year int @sorted)');
db.run('put notes {title: $1, year: $2}', ['Dunes', 2021]);
db.run('put notes {title: $1, year: $2}', ['Night at the oasis', 2024]);
const { rows } = db.run('get notes where year >= $1 order year desc', [2020]);
// [{"id":2,"title":"Night at the oasis","year":2024},{"id":1,"title":"Dunes","year":2021}]
const hits = db.rows('get notes select title match title $1 fuzzy', ['oasys']);
// [{"title":"Night at the oasis","_score":0.62239116}]That database is in the tab's memory, with no server; Opening a database below keeps it between visits, and the next section says when to load the module and when to query a server through the client alone.
Which package
Pick by where the queries run: on a server, through the client entry and
no module, or in the page, through the full module. A synced replica loads
the same module, so its near walks a graph as a database in
the page does.
| For | What it imports and loads | Brotli | What it can do | What it cannot |
|---|---|---|---|---|
| Queries on a server | @fenecdb/web/client: connect, no module | 9 KB | every query and write over HTTP through the builder, live queries over the server's subscriptions, the code's schema checked against the server's | answer without the network, or keep data in the page |
| A database in the page, or a synced replica | Fenec.open('./fenec.wasm'); sync() loads it too | 273 KB | everything: the graph, every index, the schema check, persistence and files | — |
Beside a module the page loads fenec.js and the two modules it
imports, 51 KB brotli as they are served with no build step; a bundler keeps
what the app uses of them. An app whose queries run on a server can import
the client entry instead:
import { connect } from '@fenecdb/web/client'; // or './client.js', beside builder.js and http.js
const db = connect('https://db.example.com', { token: 'a token' });
const rows = await db.from('docs').where('year', '>=', 2024).limit(10).rows();
const stop = db.live(db.from('docs').where('year', 2024), (rows) => console.log(rows.length)); // again after every write it may read- What the entry guarantees is that it cannot pull in the engine,
the sync layer or storage. The bytes it saves are few: a page that only
calls
connectnever downloaded the module through@fenecdb/webeither, since onlyFenec.openfetches it. - The same builder: a query moves between the client, a replica and a database in the page unchanged, and so do its types.
- A live query over the server runs the query there again after
a write to a collection it reads; a FenecQL text names those with
{ collections }.
What each entry bundles, and how a live query over a server runs
@fenecdb/web/client is client.js, which
re-exports the builder (builder.js) and the HTTP client
(http.js), the two modules fenec.js imports beside
its glue, persistence and the sync layer. It reaches nothing else — no
WebAssembly, no IndexedDB, no files — and a test fails if it does. An app
that calls connect and reads a builder query's rows bundles to
9 KB brotli through it and to 12 KB through @fenecdb/web,
whose Fenec class and its glue a bundler cannot drop (esbuild,
minified): 2.5 KB of JavaScript, and no .wasm either way.
Its live queries open a subscription to each collection the query reads
(GET /<collection>/changes), of a shape that holds no row:
the seed is empty, and every write to the collection is a change that runs
the query again on the server. The first rows wait for every stream's seed,
a burst of writes runs it once after the run under way, and a stream that
ends is opened again, its seed running the query, since writes may have
come between. useLiveQuery takes the endpoint as it takes a
replica. Under a scoped token the server tells such a stream of the writes
to rows the token may read, and of no other (HTTP).
A token refused, or lapsed under an open stream -- the server ends a stream
at its token's exp -- is a FenecError of status
401 to onError, and the live query stops there, for the app to
open it again with a fresh token.
Each way in the table has a full example in the repository, the same Notes app:
queries on a server from Node through the client entry
(node-server),
a database in the page kept in IndexedDB
(web-local),
and a React app over either that or a synced replica
(react).
A larger one is a shop in Next.js
(shop):
10 000 products whose pages the server renders through the client entry,
search with highlights and facets, carts under each shopper's token, and a
checkout that cannot oversell, with its Lighthouse scores, timings and
load test measured. Another is a payments ledger on Node
(ledger):
transfers, refunds and holds as guarded /batch blocks, an
append-only journal, reconciliation and a change-stream sink, held to its
invariants by 16 clients, a crash under kill -9 and its
security tests. A third is courier dispatch in Next.js
(dispatch):
a company a tenant behind fenec-shard with its plan's quota,
orders claimed by the nearest courier under a lease or held at the server
until one comes, couriers on a live map, and couriers' and customers'
tokens held to their own rows, with the gaps it found listed.
A shape's rows, as they change
db.subscribe keeps a shape -- the rows of a collection a
filter selects -- rather than a query's answer: a seed with the
whole shape, then each change, the rows written into it and the
ids that left it. The shape is the REST surface's: a field's filter,
where and select.
import { connect } from '@fenecdb/web/client';
const db = connect('https://db.example.com/t/acme', { token: 'a token' });
const tasks = new Map<number, unknown>();
const stop = db.subscribe('tasks', { team: 'eq.design', select: ['title', 'status'] }, (ev) => {
if (ev.type === 'seed') {
tasks.clear();
for (const r of ev.rows) tasks.set(r.id, r);
} else {
for (const r of ev.puts) tasks.set(r.id, r);
for (const id of ev.dels) tasks.delete(id);
}
}, {
onError: (e) => console.warn(e), // status 401: a fresh token, then subscribe again
onState: (s) => console.log(s), // 'open' at each seed, 'retry' while it waits
});A stream that ends -- a tenant moved, the network -- is opened again
(250 ms, doubling to 15 s) and seeds again; one refused or ended for its
token is not. sseEvents(res), the reader under it, takes any
fetch answer of server-sent events: EventSource
cannot send Authorization.
Batches, retries and polling
db.batch sends statements as one /batch: every
write lands or none does. Each is a builder query, a FenecQL text, or
[text, params] as toInsert, toUpdate
and toDelete make them; with an idempotencyKey a
retry after a timeout is answered as the first was.
import { connect, inc, FenecError } from '@fenecdb/web/client';
const db = connect('https://db.example.com', { token: 'a token' });
const stock = db.from('inventory');
try {
const { results, replayed } = await db.batch(
[
stock.where('sku', 'TENT-1').where('available', '>=', 2).toUpdate({ available: inc(-2) }, { require: 1 }),
db.from('orders').toInsert({ number: 'SG-1', sku: 'TENT-1', qty: 2 }),
],
{ idempotencyKey: 'checkout-7f3a' },
);
console.log(results, replayed);
} catch (e) {
// 412: the statement at `at` wrote no row where it required one, and nothing landed.
if (e instanceof FenecError && e.status === 412) console.log('sold out', e.at);
}
await db.run('put orders {number: $1}', ['SG-2'], { idempotencyKey: 'order-SG-2' });
await db.withIdempotencyKey('order-SG-3').from('orders').insert({ number: 'SG-3' });
// No stream held: the server answers 304, not running the query, while nothing it reads was written.
const stop = db.live(stock.select('available').where('sku', 'TENT-1'), (rows) => console.log(rows[0]), { poll: 5000 });- A refusal names its statement:
FenecError'sat(from 0), itsstatus(412 arequirenot met, 409 a value taken, 422 a key sent with another request) andcompleted. A database in the page names it the same way:db.runof several statements, one block, throws with theatof the one that stopped it, anddb.batchthere takes the same items and answers as a server's, each statement with its own parameters. - A write answers where it left the database,
seq, and the client keeps the last asdb.seq. { poll }is for a page many view: a stream is a server thread and a wake-up at every write, a poll holds nothing between rounds (Polling a read).{ wait: ms }holds a claim at the server until a job comes, onupdate,delete,runandbatch, and{ signal }gives it up (Waiting for a job). A database in the page refuses it: there is no other worker to wait for.connect(url, { reads: 'nearest' })reads near: the builder's reads, and arunorbatchgiven{ read: true }, are answered by the tenant's read replica in the router's region, each after the furthest position an answer from home named to this client, its change and its history — it reads nothing older than it wrote, nor a write a failover replaced; writes and subscriptions go home. Alivequery's streams stay home and each of its runs is a read near, sent after the change its stream told of and the rows it last showed too, so it shows every write home takes and never goes back; its polls go home (Read replicas). A write sent with{ read: true }is refused, 403.
Opening a database
Copy fenec.js, the builder.js and
http.js it imports, and fenec.wasm next to your
page, open the module, and run FenecQL or the query builder.
persist keeps the database in IndexedDB and
restore reads it back.
import { Fenec, persist, restore } from './fenec.js';
const db = await Fenec.open('./fenec.wasm');
db.run('create collection docs (title text, embed vector<384> @hnsw(cosine))');
db.run('put docs {title: $1, embed: $2}', ['hello', embedding]);
const { rows } = db.run('get docs near embed $1 limit 5', [queryVector]);
const titles = await db.from('docs').select('title').limit(10).rows(); // the builder
await persist(db); // into IndexedDB: the image once, then only the writes since
await restore(db); // read it back- The examples are TypeScript. The same code runs as plain
JavaScript, and the module needs no build step:
fenec.d.tsbeside it holds the types (TypeScript). db.run()is synchronous; the builder's endpoints return promises.- Text in a collation also needs the
collate/directory beside the module (below).
The four files, and why run() is synchronous
web/fenec.js is a dependency-free ES module: the
WebAssembly glue, persistence and the sync layer, with the query builder
and the HTTP client in the two modules it imports and re-exports. Copy four
files — and the collate/ directory beside the module, for text
in a collation — and you are done.
db.run() is synchronous because raw FenecQL is a single wasm
call. The builder's endpoints return promises, because a builder has to pick
its transport first.
Keeping a database
In a dedicated worker, openFile keeps the database in a file
of the origin private file system — the same file fenec-server
keeps — and each write is flushed before run returns.
persist and restore keep the same bytes in
IndexedDB, and work anywhere a page runs.
// in a dedicated worker
import { Fenec, openFile } from './fenec.js';
const db = await Fenec.open('./fenec.wasm');
const file = await openFile(db, 'notes.fenec'); // loads what it holds, or takes the database's image
db.run('put notes {text: $1}', ['in the file before run returns']);
const bytes = file.bytes(); // what fenec and fenec-server open- The file is portable: download it and
fenec notes.fenecopens it, and a server's file loads in a page. - One owner at a time: a second worker or tab that opens the file is refused.
- A crash leaves a whole file: the next
openFileholds every writerunanswered.
How openFile works, with timings
openFile keeps the database in a file of the origin private
file system, byte for byte the file fenec-server keeps on disk:
download it and fenec notes.fenec opens it, and a server's file
handed to a page loads there. run appends each statement's
writes to it and flushes the file before it answers. It needs a dedicated
worker, the one place a page gets synchronous access to a file, and it takes
the file for itself: a second worker or tab that opens the file is refused,
where a second process over a server's file would corrupt it.
A compact, and writes appended past half the image (and past
64 KB), put a new image in the file. It is written into a copy beside the
file (notes.fenec~) and flushed before the file is touched, so a
crash at any point leaves one of the two whole, and the next
openFile takes whichever holds every write run
answered. A write a crash cut short is cut off. A new image holds the live
records alone, so what updates and deletes left behind goes with it, from
the file and from persist's images alike; the page's memory
keeps those versions until it opens the file again or runs
compact, since a page has no thread to compact beside its
queries the way a server and an app do. Once the file refuses a write, every later write is
refused too; the database has to be opened from the file again.
persist and restore keep the same bytes in
IndexedDB, wherever a page runs: the image once, then only the writes since
the last call, as chunks. persist stores what was written when
it is called, and IndexedDB decides when that reaches the disk; the file is
flushed before every answer. Over 32.4 MB (61 000 rows with a
vector<128>), in a worker (make file-bench):
| IndexedDB, Chrome 153 | File, Chrome 153 | IndexedDB, Safari 26.1 | File, Safari 26.1 | |
|---|---|---|---|---|
| the first image | 92 ms | 59 ms | 418 ms | 156 ms |
| a new row stored, p50 | 0.18 ms | 0.56 ms | 0.98 ms | 0.36 ms |
| opened again | 47 ms | 20 ms | 190 ms | 118 ms |
The file's row includes its statement, 0.03 ms in Chrome and 0.10 ms in
Safari, its vector handed over as f32s, and its flush. Firefox
was not measured.
Sealed in IndexedDB
Pass a WebCrypto AES-GCM key and persist encrypts what it
stores; restore then needs the same key. Where the key comes
from is up to the page — never keep it in IndexedDB beside the data.
import { persist, restore } from './fenec.js';
// A key from a passphrase, never stored: the page asks for it again.
const salt = new TextEncoder().encode('myapp:' + userId);
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
const cryptoKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
await persist(db, 'notes', { cryptoKey }); // the image and each chunk sealed
await restore(db, 'notes', { cryptoKey }); // another key, or none, is refusedWhat the seal covers, and what it costs
With a cryptoKey (AES-GCM, from WebCrypto) the image and
every chunk are sealed before they reach IndexedDB, each with a random nonce
and a tag over the key it is stored under and its place. A chunk moved,
dropped from the middle, or left from an image before is refused, as is one
byte changed. A restore without the key refuses a sealed record, and a
restore with one refuses a record stored in the clear. Where the key comes
from is the page's decision: derived from a passphrase, as above, or handed
over by the server after a login, so that what IndexedDB holds on the disk
reads as nothing without it. A key kept in IndexedDB beside the data protects
nothing. The sync layer takes it as sync({ persist, cryptoKey }).
Its cursors and unsent writes are collections of the replica, sealed with it. The same 32.4 MB in
Chrome 154: the first image 129 ms against 76, a new row 0.16 ms p50 either
way, a restore 37 ms against 29. Only the image pays: it is 32 MB to seal.
The file of openFile stays plain, since it is the file
fenec-server opens. The disk's own encryption covers it.
State in the page
A database in the page can hold an app's whole state, with no server.
Declare its collections in code, and the open makes them.
db.live(query, fn) hands fn the query's rows now,
and again after every write to a collection the query reads. Event handlers
write, and persist keeps the state between visits.
import { Fenec, persist, restore } from './fenec.js';
import { fenecTable, text, boolean, timestamp, index } from './schema.js';
export const todos = fenecTable('todos', {
key: text(),
title: text().notNull(),
done: boolean(),
at: timestamp(),
}, (t) => [
index('todos_key').using('hash', t.key),
index('todos_done').using('hash', t.done),
index('todos_at').on(t.at),
]);
const db = await Fenec.open('./fenec.wasm', { schema: { todos } });
await restore(db, 'todos'); // the stored database in its place, checked against todos
// the rows now, and again after every write to todos
const stop = db.live(db.from(todos).where('done', false).order('at'), draw);
async function add(title: string) {
await db.from(todos).insert({ title, done: false, at: new Date() });
await persist(db, 'todos'); // only this write, not the whole image
}
async function finish(id: number) {
await db.from(todos).where('id', id).update({ done: true });
await persist(db, 'todos');
}In React, useLiveQuery from @fenecdb/react takes
the same database, and the rows it hands back are typed by the table:
import { FenecProvider, useFenec, useLiveQuery } from '@fenecdb/react';
import type { Fenec, SchemaOf } from './fenec.js';
import { todos } from './tables.js'; // the tables above, in a module of the app's
type Db = Fenec<SchemaOf<{ todos: typeof todos }>>;
function Todos() {
const db = useFenec<Db>();
const open = useLiveQuery(db.from(todos).where('done', false).order('at'));
if (open === undefined) return null;
return open.map((t) => (
<li key={t.id} onClick={() => db.from(todos).where('id', t.id).update({ done: true })}>{t.title}</li>
));
}
root.render(<FenecProvider db={db}><Todos /></FenecProvider>);To share the todos through a server later, open a
synced replica instead. The components stay as
they are: a replica has the same from and live,
and its writes reach the server. The server owns the schema there: the
tables are checked against it, and nothing is applied.
import { sync } from './fenec.js';
import { FenecProvider } from '@fenecdb/react';
import { todos } from './tables.js';
const db = await sync({
url: 'https://todos.example.com',
schema: { todos },
shapes: [{ collection: todos, key: 'key' }],
});
root.render(<FenecProvider db={db}><Todos /></FenecProvider>);The whole of it is the repository's
react
example: its database opens in the page or as a replica by one line of
src/db.ts, and the components are the same.
- One run per task: the writes made in one task, such as a
runof several statements or a loop, run each live query once. - Only what it reads: a query built with
db.fromknows its collections, including eachlookup's. A text names them:db.live('get todos count', fn, { collections: ['todos'] }). - A restore runs them all:
restore,openFileandloadhand every live query the loaded rows. livereturns its stop function. An error goes to{ onError }, ordb.onError.
How a live query learns what changed
The engine notes every write in its change ring, a collection id and a
document id each, and fenec_changes(since) names the collections
written since a cursor. A block's writes are noted as it lands, so a text of
several statements that fails names nothing. A create, an
alter and a create index name their collection. A
drop answers null, which means every collection:
a dropped collection has no name left to give. Its live query runs again and
gets the error that the collection is gone. Queries over other collections
also run again for nothing, which is harmless.
The notice costs a write nothing. A statement's answer does not carry
it. A page with a live query asks once, in a microtask after the task that
wrote: a write to a collection no live query reads costs that one call, about
2 µs in Node. Running a query again is the query itself: a page of 50 open
todos out of 1 000 to 3 000, ordered by an @sorted field, took
0.19 ms. No incremental maintenance is needed (Sync
says why). The module did not grow, and make wasm-speed's
put did not move.
A synced replica runs its live queries at the next frame instead, so that
a seed landing in chunks is not shown half done. Both share one
implementation. A load runs every live query whatever the ring
says, since the image's counter may stand where the cursor did.
With openFile, the database and its live queries live in the
dedicated worker that holds the file. The worker runs them, and posts the
rows to the page that draws them. The page holds no database to subscribe
to.
The key field in the example is what a replica reconciles an
optimistic insert by. It fills the field in for you. Without a key, the row
shows up once the server has it, not at once.
Schema in code
Declare the collections in TypeScript, the way Drizzle declares tables, and open the database with them. The open compares the database with the code: what only adds is made, and anything that could lose data is refused, listed with how to resolve it. The tables also type every query.
import { Fenec } from './fenec.js';
import { fenecTable, text, integer, json, vector, index, uniqueIndex, defineRelations } from './schema.js';
export const products = fenecTable('products', {
name: text().notNull().collate('tr'),
sku: text().unique(),
meta: json(),
embed: vector({ dimensions: 384 }),
}, (t) => [
index('products_name').using('bm25', t.name).with({ prefix: 6 }),
index('products_embed').using('hnsw', t.embed.op('vector_cosine_ops')).with({ m: 16 }),
uniqueIndex('products_lang').on(t.meta.path('lang')),
]);
export const reviews = fenecTable('reviews', {
productId: integer().notNull(),
stars: integer(),
}, (t) => [index('reviews_product').using('hash', t.productId)]);
export const relations = defineRelations({ products, reviews }, (r) => ({
products: { reviews: r.many.reviews({ from: r.products.id, to: r.reviews.productId }) },
}));
const db = await Fenec.open('./fenec.wasm', { schema: { products, reviews }, relations });
type Product = typeof products.$inferSelect; // { id: number; name: string; sku: string | null; ... }
const top = await db.from(products).select('name').lookup('reviews', { where: { stars: 5 } }).rows();
// top: { name: string; reviews: { id: number; productId: number; stars: number | null }[] }[]- Made or checked at every open. An empty database is made as
declared. One that exists gets what only adds — a collection, a field,
an index where there is none — in one block. A
restoreoropenFilechecks the database it brings in. - Refused, nothing is written. A field the code lacks (dropped,
or renamed?), a type changed, an index changed or taken off, a collation
changed, a field made required: the open throws a
FenecErrorthat names each difference and how to resolve it. - Migrations resolve them.
migrations: [rename('todos', 'title', 'name')]runs once, in order, recorded in the database's_migrations. - A schema is FenecQL too.
schemaalso takes thecreate collectionstatements of aschema.fenecqlfile, which every fenecdb SDK can hand over.
Declaring columns, indexes and relations
A column builder takes no name: the key is the field's name. Columns are
nullable unless .notNull(), FenecQL's required. A
field takes one index, declared on the column (.unique()) or in
the third argument; two on one field are refused as the table is declared,
as is anything FenecQL cannot hold. An index over several fields is
.on() with several columns, each .desc() or
.asc() as Drizzle writes it, beside a field's own
(Indexes over several fields), and
dropIndex(table, ...fields) is the migration that takes one
off.
| Declared | FenecQL |
|---|---|
text() integer() doublePrecision() boolean() | text int float bool |
timestamp() bytea() json() | timestamp bytes json |
vector({ dimensions: 384 }) / halfvec(...) | vector<384> / vector<384, f16> |
sparsevec({ dimensions: 30522 }) | sparse<30522> |
text().array() | [text] |
.notNull() .collate('tr') .nocase() .unique() | required collate tr nocase @unique |
index().on(t.at) | @sorted |
index().on(t.at).ttl('30d') | @ttl(30d) |
index().using('hash', t.n) / uniqueIndex().on(t.n) | @hash / @unique |
index().on(t.owner, t.at.desc()) / uniqueIndex().on(t.org, t.slug) | @sorted(owner, at desc) / @unique(org, slug) |
index().using('hnsw', t.e.op('vector_l2_ops')).with({ m, ef_construction, ef_search, quant }) | @hnsw(l2, m=.., ...) |
index().using('bm25', t.body).with({ k1, b, prefix, prefix_min, chars }) | @text(...) |
index().using('inverted', t.splade) | @inverted |
index().using('hash', t.meta.path('lang')) | create index on t (meta.lang) @hash |
typeof products.$inferSelect is a row as read, id
and all, a nullable column T | null: the same type fenec
types generates for the collection, which the tests hold it to field
for field. $inferInsert is what insert takes: the
.notNull() columns given, the rest optional. db.from(table),
lookup(table, ...), a shape's collection and
useLiveQuery are typed by the table, and a misspelt field or a
wrong value's type is a compile error.
A relation of defineRelations is a lookup:
on <to> = <from>, its rows attached under the table
it reaches. to must be id or a column with a hash or
unique index, since lookup finds children by an index and never
by a scan; a relation it cannot answer is refused as it is declared.
r.one attaches the first child, still in a list.
What the check does at an open
The engine compares, in the module or on the server, so every SDK applies and refuses the same things with the same words. What it does:
| Difference | At an open | Resolved by |
|---|---|---|
| A collection, a field, an index where there is none, a path index | made | — |
| A collection the code does not declare | left as it is | dropTable('t') to drop it |
| A field the code does not declare | refused: dropped, or renamed? | rename(...) or drop(...) |
| A type changed | refused | a migration: add a field, set it from the old, drop the old |
| An index changed or taken off, a collation changed | refused | rebuild('t', 'field'); an expiry: alter field |
| A required field made optional | refused | rebuild: made again, not required |
| A field made required, or a new required field | refused | declare it as the database has it |
FenecError: the database's schema differs from the code's, and nothing was applied:
- `todos.title` is in the database and not in the code
if it was renamed to `name`, the migration `alter collection todos rename field title to name`; if it goes, `alter collection todos drop field title`Everything an open applies is one block: the migrations not yet
recorded, their records, and the additions land together, or — on a
refusal or an error — none of them. A database made from the code records
its migrations without running them, since they lead from schemas it never
had. A migration is known by its place in the list and held to its text:
one changed after it ran, or a database holding more migrations than the
code lists — an older app opening what a newer one migrated — is refused,
and nothing runs. rebuild adds the field again as declared,
copies its values with set, drops the old field and renames
the new one; a field it rebuilds is not required.
db.describe() is the database's schema as the engine
describes a server's (GET /_schema), and
db.describe('fenecql') the statements that make it.
db.checkSchema(description, 'plan') says what an open would
do and writes nothing, the pending migrations run in a block put back. The
check costs an open of 10 collections 0.09 ms in Node (0.11 ms without, 0.20
with), and the module 7.5 KB brotli (23 KB raw): the comparison and its
messages, and FenecQL read into a schema. make wasm SCHEMA=0
leaves it out.
Where the check runs, and the description
A database in the page (Fenec.open, restore,
openFile) is the code's, and the check applies. A synced
replica's schema is its server's: sync({ schema }) compares
the code with the server's (POST /_schema/plan?mode=follow),
refuses what the code declares and the server lacks, and applies nothing;
the replica takes its collections as the server declares them, as it
always did. connect(url, { schema }) compares the same way;
with migrate: true and the server's token it runs the
migrations and the additions on the server (POST /_schema/apply),
as a deploy step runs drizzle-kit's migrate.
Every SDK's declarations compile to one description, versioned JSON:
{"format": 1, "collections": [...], "migrations": [...]}, a
field {"name", "type", "required", "collate", "nocase", "index", "paths"}
and an index {"kind": "hnsw", "metric", "m", ...} holding what
the declaration says, the engine's defaults for the rest — or the same
collections as FenecQL text, {"format": 1, "fenecql": "create
collection ..."}, which the module reads.
integrations/schema-golden.json holds declarations and the
description each compiles to, FenecQL texts, and plans with the statements
and refusals the engine makes of them, which the Rust tests run and every
SDK will be held to.
Coming from Drizzle, and which way to choose
One to one: a table function with columns and an array of indexes,
column builders without a name, .notNull(),
.unique(), .array(), $type,
index and uniqueIndex with .on,
.using and .with, pgvector's
vector, halfvec and sparsevec with
their operator classes, $inferSelect,
$inferInsert, getColumns and
defineRelations with r.many and
r.one.
fenecdb's own: fenecTable (also collection),
bm25 and inverted methods,
.ttl(), .collate(), t.meta.path(...),
and quant. Not there: .default() (FenecQL has no
defaults), .primaryKey() (every document has its
id), .references() (no foreign keys; a relation is
defineRelations), an index on two columns, and index names,
which are checked to be distinct and kept nowhere. Drizzle generates SQL
migrations from a diff; here the engine applies what only adds by itself,
and only what it refuses needs a migration, written beside the
schema.
Declare the schema in code for an app that makes its own database: a
page's state, an app's file, a service that owns its server. Generate the
types from the database (TypeScript) for an app
over a database another owns, a server's whose schema changes elsewhere.
To move from the second to the first, fenec types --schema
data.fenec > schema.ts writes the tables the file holds, every
option written, and fenec types --fenecql the same schema as
FenecQL; an open with either finds no difference.
Collation data
Text ordered with collate und or collate tr
needs each script's sorting data. The module carries Latin and fetches the
rest from collate/ beside it the first time a page needs
them.
import { Fenec } from './fenec.js';
const db = await Fenec.open('./fenec.wasm'); // collate/ beside it
db.run('create collection people (name text collate und @sorted)');
await db.from('people').insert([{ name: 'Ζωή' }, { name: '山田' }]); // fetches greek, han
const rows = await db.query('get people order name'); // the same, raw
await db.collation('cyrillic', 'arabic'); // ahead of the first statement that needs them
await db.collation('all'); // every script's: 145 KB, 67 KB gzip, 61 KB brotlidb.query()and the builder fetch what is missing and run the statement again.db.run()cannot wait, so it throws aFenecErrornaming the chunks to load.
How collation data is loaded
collate und and collate tr need the weights of
the scripts they compare (Collation).
The module carries the Latin ones and is handed the others as a page first
needs them, a chunk a group of scripts, from collate/ beside the
module — or from where Fenec.open(src, { collation }) says: a
URL, or a function handed the chunk's name that returns its bytes.
make wasm writes the chunks next to fenec.wasm.
A statement that would compare text of a script whose chunk the module
has not got is refused rather than answered in another order:
db.run(), which is synchronous, throws a
FenecError whose collation names the chunks, and
db.query() and the builder fetch them and run the statement
again. A write is refused before it changes anything, so running it again is
safe; of several statements in one text only the first is run again by
itself. restore and db.loadAsync() fetch what an
image's collated text needs — its @sorted indexes would be
out of order without it — and the sync layer fetches what its seed, the
changes it is sent and its optimistic writes need
(sync({ collation }) says where from). A chunk of another
version's tables is refused: its ranks would mean something else.
A smaller module
A page that does not use every feature can load a module built without
the rest. The release ships fenec.wasm alone, with every
feature; another set is a build option.
make wasm # every feature: 273 KB brotli
make wasm FEATURES="text sorted" # those two alone, and the schema check
make wasm FEATURES=indexes # the indexes, none of the rest
make wasm FEATURES=language SCHEMA=0 # no index, nor the schema check: 202 KB
make wasm FEATURES=none SCHEMA=0 # no feature at all: 179 KB| Feature | What needs it |
|---|---|
vector | nothing but speed: without the graph near over a vector field, and fuse, measure every vector, as exact does, with the same rows and scores |
text | match, and the rerank after it, which reads the vectors themselves: BM25 needs the index's statistics, so without it match is refused |
sparse | nothing but speed: without it near over a sparse<N> field scores every document, as exact does |
sorted | nothing but speed: without it the scan answers a @sorted field's comparisons and orders, with the same rows |
composite | nothing but speed: without it the scan answers an index over several fields, @sorted(owner, created desc), and a @unique(org, slug) is asked by reading the rows; it needs sorted |
geo | distance(), within(), near over a geo field, and its @geo index (with sorted; without it the scan answers): without it a point is read, written and checked, and the rest is refused |
nocase | a text compared by its case fold: without it every statement over a collection declaring one is refused, since its text compared as written would be a wrong answer |
collate | collate und and collate tr, ICU's order: without it a statement over a collection declaring one is refused, as is order ... collate |
facets | facet, its value counts, ranges and disjunctive facets |
highlight | highlight() and snippet(), which mark what match found and so need text too |
analytics | approx_count_distinct and the hll_* sketches, and bucket(); every other aggregate — count, sum, avg, min, max, count(distinct ...), first and last — and having are in every module |
What a near costs in a module built without
vector, limit 10, the median of 50 queries in Node
(make wasm-exact-speed):
| Rows × dimensions | fenec.wasm, through the graph | Built without vector |
|---|---|---|
| 1 000 × 128 | 0.20 ms | 0.38 ms |
| 10 000 × 128 | 0.22 ms | 2.4 ms |
| 50 000 × 128 | 0.34 ms | 11.7 ms |
| 1 000 × 384 | 0.47 ms | 0.90 ms |
| 10 000 × 384 | 0.51 ms | 6.2 ms |
| 50 000 × 384 | 0.75 ms | 29.4 ms |
Which features a smaller module can leave out
The module carries five indexes and six more parts of the language, and
a page that uses fewer can load one built without the rest:
FEATURES names what it keeps, indexes and
language each group. The release and @fenecdb/web
carry fenec.wasm alone; another set is a make wasm
away. What each feature weighs is in Benchmarks. The builds without
the indexes and without anything are also tests: CI builds them
(make wasm-lite, make wasm-min) and opens the
full module's files in them.
Everything else is in every build: documents, filters, order,
count, sum, avg, min and
max by group, lookup, in (get
...), json fields and their paths, @ttl, the change
feed, persistence and the sync layer.
The file does not change with the build. A collection that declares an
index the module lacks is made and loaded all the same, and its documents
are read and written. A near over it measures every vector, a
sparse one scores every document; a match, or a create
index of the kind, throws a FenecError naming the
feature. A file declaring a nocase or collated field loads too;
a statement over that collection throws, the feature named, and the file's
other collections answer. A module that has the feature builds what it
needs when it loads what the other wrote, so a page can hand a store to the
other module and take it back.
The query builder
The builder writes FenecQL for you, with every value passed as a parameter. Each call returns a new query, so a base query can be shared and extended with optional filters.
const rows = await db.from('articles')
.select('title', 'year')
.where('year', '>=', 2024)
.where('tags', 'has', 'rust')
.near('embed', queryVector, { ef: 128 })
.limit(10)
.rows();- Each
whereisanded with the ones before it. - Queries are immutable, so
if (year) q = q.where(...)branches safely. - No npm, no build step, and no second schema definition.
Why a builder instead of strings or an ORM
FenecQL reads well, but on the application side the job is still building a string — and every interface with conditional filters ends up here:
let q = 'get articles where 1=1';
if (year) q += ` and year >= ${year}`; // quote escaping and $n by handWhat usually drives people to an ORM is not the ORM but two things: dynamic queries and type safety. The builder gives both, without npm, a build step, or a second schema definition.
Every where is anded with the previous one, and the
query object is immutable — so a body can be shared and branched:
let q = db.from('articles').select('title');
if (year) q = q.where('year', '>=', year);
if (tag) q = q.where('tags', 'has', tag);
const rows = await q.limit(20).rows();Filters
A filter can be written three ways, which all build the same query. The table lists everything else a query can carry.
const articles = db.from('articles');
articles.where('year', '>=', 2024); // field, operator, value
articles.where('category', 'book'); // two arguments means equality
articles.where({ year: { gte: 2024 }, tags: { has: 'rust' } });
articles.where('meta.lang', '=', 'tr'); // a path into a json fieldA path into a json field
goes wherever a field does — where, select,
order, and the keys of update, which sets one key
inside the field — and a plain object is a json field's value:
insert({ meta: { lang: 'tr' } }). fenec types
writes a json field as Json, and the builder's
types take 'meta.lang' as a path of it.
| Operators | = != < <= > >= ~ has in |
| Word forms | eq ne lt lte gt gte like has in |
| Null | { summary: null } becomes is null; { summary: { not: null } } becomes is not null |
| Combining | or(...) not(...) and(...) — where already ands |
| Escape hatch | raw('cosine(embed, ?) > ?', vector, 0.5) |
| Ordering | order('year','desc').order('title') — successive calls add keys; order('name', 'asc', { collate: 'und' }) puts text in Unicode's order for every language, { collate: 'tr' } in Turkish order (Collation) |
| Ranking | near('embed', v, { ef, exact }), match('body', text), rerank('embed', v, { candidates }) after a match, and match(...).near(...).fuse({ k, candidates }) to rank by both (Hybrid search) |
| Relations | lookup('reviews', { on: 'product_id', limit: 3 }) — children per row, chainable |
| Endpoints | rows() first() count() run(), and explain(): the path the query took, one line a step (Explain) |
| Writes | insert(doc|docs) update(object) delete(), each resolving to the rows it changed; insert(doc, { ifAbsent: true }) passes over a held id or @unique value (put … if absent); order(...) and limit(n) before an update or a delete pick its rows, and { returning: true } resolves to them — a job queue's claim (Job queues) |
| Values worked out | update({ n: inc(1) }) renders n: coalesce(n, 0) + $1, and expr('price * ?', 1.2) any expression over the row — see Writes that read the row and Instead of Redis |
lookup, or, not and raw
Use whichever spelling reads better at the call site.
lookup hangs another collection's matching documents off each
row. Everything in its options object binds to that collection, so
limit there counts children per parent — twenty products with
three reviews each, in one query:
const rows = await db.from('products')
.where('price', '>=', 10000)
.limit(20)
.lookup('reviews', {
on: 'product_id',
where: { stars: { gte: 4 } },
order: [['created', 'desc']],
limit: 3,
})
.rows();
// [{ id: 1, name: 'Kahve', price: 12000, reviews: [ {...}, {...} ] }, ...]Calling it again chains rather than replaces: the second call binds to the collection the first one named, the way position scopes it in FenecQL.
const rows = await db.from('shops')
.limit(20)
.lookup('orders', { on: 'shop_id', limit: 3 })
.lookup('lines', { on: 'order_id', limit: 5 })
.rows();
// [{ name: 'Merkez', orders: [{ code: 'A', lines: [ {...} ] }, ...] }, ...]A level's select may aggregate, as the query's own does:
each parent's children folded — one row a parent, or with
group one a group, ordered and paged a parent at a time.
Ungrouped, order and limit pick the children
folded. A query whose list aggregates hangs a lookup off its groups,
parentKey naming a column; and bucket(field, interval,
zone) counts by a zone's clock, the zone a parameter.
import { bucket } from './fenec.js';
// Each product's latest 50 reviews, counted and averaged.
const page = await db.from('products').limit(20)
.lookup('reviews', { on: 'product_id', order: [['created', 'desc']], limit: 50,
select: ['count(*)', 'avg(stars)'] })
.rows();
// The ten most reviewed, with their names.
const most = await db.from('reviews')
.select('product_id', 'count(*)').group('product_id').order('count(*)', 'desc').limit(10)
.lookup('products', { on: 'id', parentKey: 'product_id', select: ['name'] })
.rows();
// Reviews a day, by the page's own clock.
const days = await db.from('reviews')
.select(bucket('created', '1d', Intl.DateTimeFormat().resolvedOptions().timeZone).as('day'), 'count(*)')
.group('day').rows();The row type follows the nesting, so
rows[0].orders[0].lines[0] is typed without a cast; a level
that folds is typed as the children it folds, its columns not narrowed to.
required keeps meaning what it means at its own level: on
lines it drops the orders that have none, and the shops
stay unless orders asks for it too.
The child's field names cannot be reached from a builder that carries one
collection's types, so in TypeScript they default to a loose row; naming them
tightens it:
.lookup<'reviews', FenecSchema['reviews']>('reviews', {...}).
or and not are separate functions because the
where chain is an and. A nested group is written
explicitly and the parenthesising is left to the builder:
import { or } from './fenec.js';
db.from('articles')
.where('year', '>=', 2024)
.where(or({ tags: { has: 'rust' } }, { title: { like: 'rust' } }))
// get articles where year >= $1 and (tags has $2 or title ~ $3)raw covers what the builder cannot express, which in practice
today means function calls. Its ? placeholders bind to parameters
in order, so the escape hatch does not become string concatenation either.
A search page
A results list with the words marked, and a sidebar counting each brand
and color over every match, from one query:
highlight() and facet().
import type { Mark } from '@fenecdb/web';
/** The text with each mark in a <mark>, as text nodes: nothing in it is HTML. */
function marked(text: string, marks: Mark[]): DocumentFragment {
const out = document.createDocumentFragment();
let at = 0;
for (const [start, end] of marks) {
const m = document.createElement('mark');
m.textContent = text.slice(start, end);
out.append(text.slice(at, start), m);
at = end;
}
out.append(text.slice(at));
return out;
}
async function search(words: string, brand: string | null = null) {
let q = db.from('products')
.select('name', 'price')
.highlight('name')
.match('name', words, { fuzzy: true, prefix: true })
.facet('brand', { top: 10 })
.facet('color')
.limit(20);
if (brand) q = q.where('brand', brand);
const rows = await q.rows();
results.replaceChildren(...rows.map((r) => {
const li = document.createElement('li');
li.append(marked(r.name ?? '', r['highlight(name)'] ?? []), ` ${r.price}`);
return li;
}));
sidebar.replaceChildren(...rows.facets.brand.map((f) => {
const b = document.createElement('button');
b.textContent = `${f.value ?? 'no brand'} (${f.count})`;
b.onclick = () => search(words, f.value);
return b;
}));
}- It finds what is being typed:
fuzzyreads each word within a typo or two,prefixthe last word as the start of one —databseanddatabboth finddatabase, and the marks fall on it (Typos and words begun). - The offsets are a JavaScript string's own (UTF-16), so
text.slice(start, end)is the marked word, past an emoji or an accent. rows.facetscounts every match, not only the twenty on the page; typed by the fields:f.valueis astring | nullhere.- The same query live:
useLiveQuery(q)hands rows with theirfacets, asdb.livedoes.
Highlights, snippets and facets in the builder
.highlight(field, { pre, post }) answers the marked text
instead of the offsets, and .snippet(field, words, { ellipsis, pre,
post }) a window of that many words around the marks — see Highlights and snippets. Text with
tags is not escaped, which is why the page above builds its nodes from the
offsets. .facet(field, { top }) takes a field or a path into a
json one, and run() hands the counts as its
facets too — see Facets.
A facet beside near is refused: near ranks every
row rather than selecting some, so the counts would be the filter's alone.
{ disjunctive: true } counts a facet without the filter's own
conditions on its field, and { ranges: [0, 2500, 5000] } counts
a number by range (
Counted without its own filter, Ranges).
A query built in steps keeps the type it was declared with, so a facet
asked after the declaration is typed as any facet: rows.facets
is there, each field's counts FacetCount[], and maybe absent.
To keep each field's own type, ask the facets in the declaration and add
the steps that keep the row's type after it:
import { connect } from '@fenecdb/web/client';
const db = connect('https://db.example.com');
const chosen: string | undefined = 'acme';
let q = db.from('products').select('name').facet('brand', { disjunctive: true });
if (chosen) q = q.where('brand', chosen);
const rows = await q.limit(24).rows();
rows.facets.brand.map((f) => f.count); // typedSeeing what it produces
To see the text and parameters a chain builds, for logging or a test,
call toFenecQL(). It returns exactly what will run, and runs
nothing; a query needs no call to it before it runs.
db.from('articles').where('year', '>=', 2024).limit(10).toFenecQL()
// ['get articles where year >= $1 limit $2', [2024, 10]]- Values are always parameters, never pasted into the text.
- Collection and field names are checked against FenecQL's identifier rule; anything else throws.
How values and names stay out of the query text
Nothing is hidden. Every leaf value is bound to a parameter and text is never embedded into the query, which leaves a single injection boundary: names. Collection and field names cannot be parameterised, so they are validated against FenecQL's identifier rule and anything that does not fit errors out.
db.from('articles').where('title', '"; del articles; --')
// get articles where title = $1 ← a value, not textA parameter that is a vector — a Float32Array, or any array
of finite numbers, which the module reads as a vector either way — goes into
the module as its f32s beside the JSON rather than as text:
200 768-dim vectors are put in 4.4 ms, against 21.9 written out as digits
and read back. The vector stored is the same, -0 as
0 as JSON writes it; over HTTP a parameter is JSON. Where such
a parameter is given a json field, or compared with a path
into one, the module names it before running anything and run
sends it again as JSON, so a plain array keeps its numbers as written and a
Float32Array goes as the numbers it holds.
Independent of the transport
The same query code runs against the module in the page, a
fenec-server server or an HTTP endpoint: build it with
from(), then bind() it to where it should run.
import { from, connect } from './fenec.js';
const q = from('articles').where('year', '>=', 2024);
await q.bind(db).rows(); // wasm, local
await q.bind(connect('http://localhost:8080')).rows(); // remote server
q.toFenecQL(); // or just the textWhat connect() sends
connect() returns the same builder; the FenecQL it produces
goes to POST /query as is. See HTTP
endpoint.
TypeScript
fenec.d.ts beside the module types the client, and
@fenecdb/web ships it. A schema declared in code types it
(Schema in code); for a database another owns,
generate the schema type from the database file, and queries are checked
against its real schema: unknown fields, wrong value types and unselected
fields are compile errors.
fenec types data.fenec > web/fenec-schema.d.ts # or: make types FILE=data.fenec
fenec types schema.fenecql > web/fenec-schema.d.ts # from a schema file, with no database
fenec types --lang python data.fenec # a row in Python, Go, C#, Swift, Kotlin or Dartimport { Fenec } from './fenec.js';
import type { FenecSchema } from './fenec-schema.js';
const db = await Fenec.open<FenecSchema>('./fenec.wasm');
const rows = await db.from('articles').select('title', 'year').rows();
// rows: { title: string | null; year: number | null }[]
await db.from('articles').insert({ title: 'Dunes', year: 2021, published: new Date() });
const products = await db.from('products')
.lookup<'reviews', FenecSchema['reviews']>('reviews', { on: 'product_id', limit: 3 })
.rows();
// products[0].reviews[0].stars: number | nullHow types map, and what is generated
The schema is not written a second time — it is generated from the live file, so the two cannot drift apart. A schema declared in code is written a second time on purpose, and the check at every open is what keeps the two one.
| Schema | TypeScript |
|---|---|
bool int float text | boolean number number string |
timestamp | Timestamp — ISO text on read; a Date or number works on write |
vector<N> / bytes | Vector / Bytes — number[] on read, each component the shortest decimal that reads back as its f32 (0.1 as written, not 0.10000000149011612); Float32Array also accepted on write |
[type] | type[] |
| a field that is not required | | null — an unwritten field reads as null |
What the types catch: a collection or field that does not exist, a wrong
value type, access to an unselected field, near applied to a
non-vector field, and a type mismatch inside insert. Objects
inside or, and and not are checked
against the query's collection rather than the first argument. A query using
near or match gains _score: number.
A standalone from() cannot know the collection name, since the
schema lives in the call rather than in db. In TypeScript it binds
through TypedFrom; at runtime it is the same function and the only
difference is name completion.
import { from as fenecFrom, type TypedFrom } from './fenec.js';
import type { FenecSchema } from './fenec-schema.js';
const from: TypedFrom<FenecSchema> = fenecFrom;
from('articles').where('year', '>=', 2024); // completes like db.fromfenec.d.ts is hand-written — it is the client's own surface —
and fenec-schema.d.ts is generated. There is no link between them:
the Timestamp, Vector and Bytes brands
are structural, so the generated file stands alone and imports nothing.
What the builder will not do
The builder adds nothing FenecQL lacks, and refuses a few things that are easy to get wrong.
- No aggregate FenecQL lacks. Aggregates go in
selectspelled as FenecQL spells them —.select('status', 'count(*)', 'sum(total)').group('status')— and each row answers under those names; a median or a distinct count is not there to compile to. - An
updateordeletewithout a filter is refused. Covering a whole collection by accident is far too easy and impossible to undo; when it is deliberate you writedelete({ all: true }). PostgREST decides the same. near,order,limitandlookuperror out on write statements. FenecQL does not support them onsetordel, and ignoring them silently would breed the illusion thatlimit(1)deletes a single row, or that a requiredlookupspares the parents with no children.- No JOIN, no
returning. The builder adds no capability on top of FenecQL — it only builds it safely.lookupis the exception FenecQL itself makes, and.lookup(name, opts)builds it like any other clause; a query given as anin's value is FenecQL'sin (get …), its parameters bound with the outer one's.
How the builder is tested
The Python, Go and .NET clients have the same builder, and
integrations/builder-golden.json holds all four to one text: the
chains there and what this builder makes of each, which every language's
tests run (Your language).
The tests run with node --test web/fenec.test.js and are part
of make test. When web/fenec.wasm is present, an
end-to-end test that actually parses the generated text runs too.