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/web
src/main.js, in a Vite project
import { 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.

ForWhat it imports and loadsBrotliWhat it can doWhat it cannot
Queries on a server@fenecdb/web/client: connect, no module9 KBevery query and write over HTTP through the builder, live queries over the server's subscriptions, the code's schema checked against the server'sanswer without the network, or keep data in the page
A database in the page, or a synced replicaFenec.open('./fenec.wasm'); sync() loads it too273 KBeverything: 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 connect never downloaded the module through @fenecdb/web either, since only Fenec.open fetches 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's at (from 0), its status (412 a require not met, 409 a value taken, 422 a key sent with another request) and completed. A database in the page names it the same way: db.run of several statements, one block, throws with the at of the one that stopped it, and db.batch there 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 as db.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, on update, delete, run and batch, 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 a run or batch given { 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. A live query'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.ts beside 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.fenec opens 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 openFile holds every write run answered.
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 153File, Chrome 153IndexedDB, Safari 26.1File, Safari 26.1
the first image92 ms59 ms418 ms156 ms
a new row stored, p500.18 ms0.56 ms0.98 ms0.36 ms
opened again47 ms20 ms190 ms118 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 refused
What 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 run of several statements or a loop, run each live query once.
  • Only what it reads: a query built with db.from knows its collections, including each lookup's. A text names them: db.live('get todos count', fn, { collections: ['todos'] }).
  • A restore runs them all: restore, openFile and load hand every live query the loaded rows.
  • live returns its stop function. An error goes to { onError }, or db.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 restore or openFile checks 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 FenecError that 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. schema also takes the create collection statements of a schema.fenecql file, 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.

DeclaredFenecQL
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:

DifferenceAt an openResolved by
A collection, a field, an index where there is none, a path indexmade—
A collection the code does not declareleft as it isdropTable('t') to drop it
A field the code does not declarerefused: dropped, or renamed?rename(...) or drop(...)
A type changedrefuseda migration: add a field, set it from the old, drop the old
An index changed or taken off, a collation changedrefusedrebuild('t', 'field'); an expiry: alter field
A required field made optionalrefusedrebuild: made again, not required
A field made required, or a new required fieldrefuseddeclare 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 brotli
  • db.query() and the builder fetch what is missing and run the statement again.
  • db.run() cannot wait, so it throws a FenecError naming 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
FeatureWhat needs it
vectornothing but speed: without the graph near over a vector field, and fuse, measure every vector, as exact does, with the same rows and scores
textmatch, and the rerank after it, which reads the vectors themselves: BM25 needs the index's statistics, so without it match is refused
sparsenothing but speed: without it near over a sparse<N> field scores every document, as exact does
sortednothing but speed: without it the scan answers a @sorted field's comparisons and orders, with the same rows
compositenothing 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
geodistance(), 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
nocasea 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
collatecollate und and collate tr, ICU's order: without it a statement over a collection declaring one is refused, as is order ... collate
facetsfacet, its value counts, ranges and disjunctive facets
highlighthighlight() and snippet(), which mark what match found and so need text too
analyticsapprox_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 × dimensionsfenec.wasm, through the graphBuilt without vector
1 000 × 1280.20 ms0.38 ms
10 000 × 1280.22 ms2.4 ms
50 000 × 1280.34 ms11.7 ms
1 000 × 3840.47 ms0.90 ms
10 000 × 3840.51 ms6.2 ms
50 000 × 3840.75 ms29.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 where is anded 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 hand

What 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 field

A 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 formseq ne lt lte gt gte like has in
Null{ summary: null } becomes is null; { summary: { not: null } } becomes is not null
Combiningor(...) not(...) and(...) — where already ands
Escape hatchraw('cosine(embed, ?) > ?', vector, 0.5)
Orderingorder('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)
Rankingnear('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)
Relationslookup('reviews', { on: 'product_id', limit: 3 }) — children per row, chainable
Endpointsrows() first() count() run(), and explain(): the path the query took, one line a step (Explain)
Writesinsert(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 outupdate({ 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 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: fuzzy reads each word within a typo or two, prefix the last word as the start of one — databse and datab both find database, 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.facets counts every match, not only the twenty on the page; typed by the fields: f.value is a string | null here.
  • The same query live: useLiveQuery(q) hands rows with their facets, as db.live does.
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);   // typed

Seeing 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 text

A 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 text
What 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 Dart
import { 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 | null
How 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.

SchemaTypeScript
bool int float textboolean number number string
timestampTimestamp — ISO text on read; a Date or number works on write
vector<N> / bytesVector / 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.from

fenec.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 select spelled 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 update or delete without a filter is refused. Covering a whole collection by accident is far too easy and impossible to undo; when it is deliberate you write delete({ all: true }). PostgREST decides the same.
  • near, order, limit and lookup error out on write statements. FenecQL does not support them on set or del, and ignoring them silently would breed the illusion that limit(1) deletes a single row, or that a required lookup spares the parents with no children.
  • No JOIN, no returning. The builder adds no capability on top of FenecQL — it only builds it safely. lookup is the exception FenecQL itself makes, and .lookup(name, opts) builds it like any other clause; a query given as an in's value is FenecQL's in (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.