fenecdb

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 back

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.

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 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.

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 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
Endpointsrows() first() count() run() toFenecQL()
Writesinsert(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 text

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

connect() 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.fenec
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; year: number | null }[]
SchemaTypeScript
bool int float textboolean number number string
timestampTimestamp — ISO text on read; a Date or number works on write
vector<N> / bytesVector / Bytesnumber[] 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.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

  • No aggregation other than count. count() compiles to the language's own count clause and rows are not decoded, but there is no sum, avg or group by. The builder cannot invent what FenecQL lacks.
  • 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 and limit 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.
  • 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.