JavaScript
web/fenec.js is one dependency-free ES module:
the WebAssembly glue, the query builder, the HTTP client and the sync layer.
Copy two files and you are done.
Opening a database
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]);
await persist(db); // snapshot into IndexedDB
await restore(db); // read it backdb.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.
The query builder
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.
const rows = await db.from('articles')
.select('title', 'year')
.where('year', '>=', 2024)
.where('tags', 'has', 'rust')
.near('embed', queryVector, { ef: 128 })
.limit(10)
.rows();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
Three spellings compile to the same thing. Use whichever reads better at the call site.
.where('year', '>=', 2024) // field, operator, value
.where('category', 'book') // two arguments means equality
.where({ year: { gte: 2024 }, tags: { has: 'rust' } })| 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 |
| Endpoints | rows() first() count() run() toFenecQL() |
| Writes | insert(doc|docs) update(object) delete() |
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:
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.
Seeing what it produces
Nothing is hidden. toFenecQL() returns exactly the text and
parameters that will run — loggable, testable, runnable by hand.
db.from('articles').where('year', '>=', 2024).limit(10).toFenecQL()
// ['get articles where year >= $1 limit 10', [2024]]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 textIndependent of the transport
from() builds a query on its own and bind() plugs
it into an executor, so the same query code runs unchanged against wasm, a
fenec-pg server, or an HTTP endpoint.
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 textconnect() returns the same builder; the FenecQL it produces
goes to POST /query as is. See HTTP
endpoint.
TypeScript
The schema is not written a second time — it is generated from the live file, so the two cannot drift apart.
fenec types data.fenec > web/fenec-schema.d.ts # or: make types FILE=data.fenecimport { 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; year: number | null }[]| 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, 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 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
- No aggregation other than
count.count()compiles to the language's owncountclause and rows are not decoded, but there is nosum,avgorgroup by. The builder cannot invent what FenecQL lacks. - 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,orderandlimiterror out on write statements. FenecQL does not support them onsetordel, and ignoring them silently would breed the illusion thatlimit(1)deletes a single row.- No JOIN, no subquery, no
returning. The builder adds no capability on top of FenecQL — it only builds it safely.
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.