Collections and types

What a collection declares and what a field holds: json and paths, points, time, collation and case.

A collection declares its fields and their types, and the type decides what a field holds, how it compares and which indexes it takes: bool, int, float, text, bytes, timestamp, lists such as [text], json, geo and the vectors.

Run in the shell, answers and all; the timings are from one run.

fenec> create collection people (name text collate tr @sorted, email text nocase @unique, meta json);
collection `people` created
fenec> put people [{name: "Zeynep", email: "Zeynep@Example.com", meta: {plan: "pro", seats: 3}}, {name: "Çağla", email: "cagla@example.com", meta: {plan: "free", seats: 1}}, {name: "İlker", email: "ilker@example.com", meta: {plan: "free", seats: 1}}, {name: "Irmak", email: "irmak@example.com", meta: {plan: "free", seats: 1}}, {name: "Cem", email: "cem@example.com", meta: {plan: "pro", seats: 5}}];
5 records (1.43ms)
fenec> describe people
people
  name           text collate tr  @sorted
  email          text nocase     @unique
  meta           json          
fenec> get people select name order name
name
------
Cem
Çağla
Irmak
İlker
Zeynep
(5 rows, 112.33µs)
fenec> get people select email where email = "ZEYNEP@example.com"
email
------------------
Zeynep@Example.com
(1 rows, 148.46µs)
fenec> get people select name, meta.plan where meta.seats >= 3 order meta.seats desc
name    meta.plan
------  ---------
Cem     pro
Zeynep  pro
(2 rows, 138.71µs)
fenec> alter collection people add field city text;
field `city` added to `people`
fenec> get people select name, city where name = "Irmak"
name   city
-----  ----
Irmak  -
(1 rows, 125.58µs)

describe shows the schema the file holds, and the shell prints a null as -. The names come out in Turkish order because the field says collate tr (Collation); the email compares by its case fold because it is nocase (Case-insensitive text); meta.plan is a path into a json field (Objects and paths); and the field added last reads null in every row written before it, with no document rewritten (Changing a collection). The grammar of create collection is under Statements.

Objects and paths

A json field holds any value JSON does — an object, a list, a number, text, true, false or null — with no type declared for what is inside. A path reads into it wherever a field is read.

create collection docs (title text, meta json)
put docs {title: "a", meta: {lang: "tr", source: {site: "x", rank: 3}}}

get docs where meta.lang = "tr" and meta.source.rank >= 2
get docs select title, meta.source.site order meta.source.rank desc
set docs {meta.lang: "en"} where title = "a"
import { connect } from '@fenecdb/web/client'
await db.from('docs')
  .insert({ title: 'a', meta: { lang: 'tr', source: { site: 'x', rank: 3 } } });

await db.from('docs')
  .where('meta.lang', 'tr')
  .where('meta.source.rank', '>=', 2)
  .rows();

await db.from('docs')
  .select('title', 'meta.source.site')
  .order('meta.source.rank', 'desc')
  .rows();

await db.from('docs').where('title', 'a').update({ 'meta.lang': 'en' });
from fenecdb import Client
(db.collection("docs")
    .insert({"title": "a", "meta": {"lang": "tr", "source": {"site": "x", "rank": 3}}}))

(db.collection("docs")
    .where("meta.lang", "tr")
    .where("meta.source.rank", ">=", 2)
    .rows())

(db.collection("docs")
    .select("title", "meta.source.site")
    .order("meta.source.rank", "desc")
    .rows())

db.collection("docs").where("title", "a").update({"meta.lang": "en"})
import fenecdb "github.com/fenecdb/fenecdb/integrations/go"
db.From("docs").
	Insert(
		ctx,
		fenecdb.D("title", "a", "meta", fenecdb.D("lang", "tr", "source", fenecdb.D("site", "x", "rank", 3))),
	)

db.From("docs").
	Where("meta.lang", "=", "tr").
	Where("meta.source.rank", ">=", 2).
	Rows(ctx)

db.From("docs").
	Select("title", "meta.source.site").
	Order("meta.source.rank", "desc").
	Rows(ctx)

db.From("docs").
	Where("title", "=", "a").
	Update(ctx, fenecdb.D("meta.lang", "en"))
using FenecDb;
await db.From("docs")
    .InsertAsync(new { title = "a", meta = new { lang = "tr", source = new { site = "x", rank = 3 } } });

await db.From("docs")
    .Where("meta.lang", "tr")
    .Where("meta.source.rank", ">=", 2)
    .RowsAsync();

await db.From("docs")
    .Select("title", "meta.source.site")
    .Order("meta.source.rank", "desc")
    .RowsAsync();

await db.From("docs")
    .Where("title", "a")
    .UpdateAsync(new Dictionary<string, object?> { ["meta.lang"] = "en" });
import FenecDB
try await db.from("docs")
    .insert(["title": "a", "meta": ["lang": "tr", "source": ["site": "x", "rank": 3]]] as Value)

try await db.from("docs")
    .where("meta.lang", "tr")
    .where("meta.source.rank", ">=", 2)
    .rows()

try await db.from("docs")
    .select("title", "meta.source.site")
    .order("meta.source.rank", "desc")
    .rows()

try await db.from("docs")
    .where("title", "a")
    .update(["meta.lang": "en"] as Value)
import com.fenecdb.*
db.from("docs")
    .insert(mapOf("title" to "a", "meta" to mapOf("lang" to "tr", "source" to mapOf("site" to "x", "rank" to 3))))

db.from("docs")
    .where("meta.lang", "tr")
    .where("meta.source.rank", ">=", 2)
    .rows()

db.from("docs")
    .select("title", "meta.source.site")
    .order("meta.source.rank", "desc")
    .rows()

db.from("docs").where("title", "a").update(mapOf("meta.lang" to "en"))
import 'package:fenecdb/fenecdb.dart';
await db.from('docs')
    .insert({'title': 'a', 'meta': {'lang': 'tr', 'source': {'site': 'x', 'rank': 3}}});

await db.from('docs')
    .where('meta.lang', 'tr')
    .where('meta.source.rank', '>=', 2)
    .rows();

await db.from('docs')
    .select(['title', 'meta.source.site'])
    .order('meta.source.rank', 'desc')
    .rows();

await db.from('docs').where('title', 'a').update({'meta.lang': 'en'});
sends
put docs {title: $1, meta: $2}
-- ["a", {"lang": "tr", "source": {"site": "x", "rank": 3}}]
get docs where meta.lang = $1 and meta.source.rank >= $2
-- ["tr", 2]
get docs select title, meta.source.site order meta.source.rank desc
set docs {meta.lang: $1} where title = $2
-- ["en", "a"]
create index on docs (meta.lang) @hash
create index on docs (meta.source.rank) @sorted
  • A path that leads nowhere is null: a key a document does not have, or a value on the way that is not an object.
  • A path takes @hash, @unique and @sorted, and they answer what the scan would, row for row.
  • set with a path changes one key and keeps the rest of the object; it makes the object where there is none.
  • Typed fields are unchanged. An int or a text field still refuses an object.
How objects keep their keys and numbers, and what paths cost

An object's keys are kept sorted and unique: a key given twice — in FenecQL, in a JSON body, in a parameter — refuses the write rather than keep one of them, since which one a reader keeps differs from reader to reader. Its numbers are kept as written, an integer an integer and 19.99 the decimal it is, everywhere one comes in: FenecQL, a parameter over HTTP and in the browser, a JSON body, a row fenec import reads. A list of numbers alone is read the quick way, into the f32s of the vector it nearly always is; where it is given a json field, or compared with a path into one, the statement's text or the parameter's JSON is read again as written, so [0.1, 12345678901] is kept as those two numbers. A vector that was never text — a Float32Array handed to the browser module, a vector built through the Rust API — is refused there rather than kept as numbers it was not given. A timestamp becomes its ISO text, as JSON writes one; bytes, a sparse vector and a number that is not finite have no JSON and are refused. A value nests at most 64 levels, and a path names at most 64 keys past its field: past either a write or a query is refused, never cut.

A path is a field's name, a dot, and keys, each a name as a field's is (meta.source.rank); a key with a space or a dash in it cannot be named, and is reached by reading the whole field. It works in every comparison, in, has, ~ and is null, in select — answering under the path's text as its column's name — in order, and in set and put, which set the one key inside the field. A filter binds a path once a query, as it binds a field, and walks its keys over each row's bytes, passing the members before the key without decoding them. An aggregate and group read a field, not a path; a lookup's keys are fields too. Comparisons are the ones every value already has: 3 equals 3.0, every number orders below every text, text below a list and a list below an object, and null is equal only to null.

An index on a path is a field of its own beside the fields, named by the path, and its kind is the field's: @hash keys a value as a typed field's does, a whole float filed as the int it equals so that 3 and 3.0 share a bucket; an equality or an in reaches it with null, a boolean, text and a number an f64 holds exactly, and anything else — an int past 253, which equals floats near it that no bucket holds — is answered by the scan. @unique refuses a value held twice as a field's does, 3 and 3.0 the same value. @sorted orders the numbers as f64s and the text by its bytes, every number before every text; a value it has no place for — true or false, a list, an object, an int past 253 — is held apart, and while the index holds one it answers nothing and the scan does: a wrong order is never what an index costs. tests/json.rs holds both to a twin collection without them, every filter, order and page, through writes and a reopen. Text, vector and sparse indexes stay on the fields that declare their type. On a server an index on a path is built under the write lock, as it reads one value a row: 11 ms for @hash and 21 for @sorted over 100 000 documents.

Renaming a json field renames the paths into it, indexes and all, and dropping it drops them. In the file a json field is type tag 14 and an object value tag 13 — not 11 or 12, which a schema writes for a collation and a dropped place — and each index on a path is written inside its field's entry, its keys and its kind: a binary from before them refuses the file (unknown type tag 14) rather than read it. The indexes are derived, as every hash and ordered index is: rebuilt from the documents by the first read after an open.

Measured over 100 000 documents of a title, a year, a price and a meta object of three keys: where meta.lang = "tr" scans in 5.7 ms where the same comparison of a text field takes 4.2; meta.source.rank >= 990 in 7.2; both together in 10.4; the first 20 by meta.source.rank in 7.3. With @hash and @sorted on the two paths: 0.025, 0.009, 0.12 and 0.010 ms. A scan of a collection with no json field takes what it took before them (3.47 against 3.38 ms by two numbers, 4.17 against 4.21 by a text, 3.06 against 3.08 by ~, 2.72 against 2.60 for an ordered page), and the browser module's filter 1.67 against 1.66.

Points

A geo field holds a point on the earth, [lon, lat] in degrees. distance measures in metres, within tests a box, and near over the field puts rows nearest first. The recipes, and what they cost against PostGIS and Redis, are in Points and distances.

create collection places (name text, loc geo @geo)
put places {name: "Catania", loc: [15.087269, 37.502669]}
get places select name, distance(loc, $1) as m where distance(loc, $1) <= 2000
get places where within(loc, [12, 37, 16, 39]) count
get places select name near loc $1 limit 10
  • distance(a, b) is the haversine on a sphere of Redis's radius, 6 372 797.56 m: Redis's GEODIST. PostGIS's sphere, the earth's mean radius, gives 0.03% less. Either side is a point field or a point; a null on either side is null.
  • within(p, [west, south, east, north]) holds a point on the box's edge; a west east of its east crosses the antimeridian.
  • near loc p orders by distance, a tie by id, with the metres as _score; exact measures every row, and ef, a graph's, is refused.
  • @geo narrows distance(loc, p) <= r (or <, either way round) and within(loc, box) in an and chain, and walks near nearest first. It is derived, as @sorted is: built by the first read after an open, nothing in the file but the field's declaration.
  • A point is refused past 180 degrees of longitude or 90 of latitude, and a list of other than two numbers.

Time

A timestamp field takes an ISO-8601 date or epoch milliseconds, and always comes back as ISO-8601 in UTC.

put events {name: "login", t: "2026-09-19T12:34:56Z"}
put events {name: "logout", t: 1758285296000}        -- epoch ms
get events where t >= "2026-01-01" and t < now()
import { connect, raw } from '@fenecdb/web/client'
await db.from('events')
  .insert({ name: 'login', t: '2026-09-19T12:34:56Z' });

await db.from('events').insert({ name: 'logout', t: 1758285296000 });

await db.from('events')
  .where('t', '>=', '2026-01-01')
  .where(raw('t < now()'))
  .rows();
from fenecdb import Client, raw
(db.collection("events")
    .insert({"name": "login", "t": "2026-09-19T12:34:56Z"}))

db.collection("events").insert({"name": "logout", "t": 1758285296000})

(db.collection("events")
    .where("t", ">=", "2026-01-01")
    .where(raw("t < now()"))
    .rows())
import fenecdb "github.com/fenecdb/fenecdb/integrations/go"
db.From("events").
	Insert(ctx, fenecdb.D("name", "login", "t", "2026-09-19T12:34:56Z"))

db.From("events").
	Insert(ctx, fenecdb.D("name", "logout", "t", 1758285296000))

db.From("events").
	Where("t", ">=", "2026-01-01").
	WhereCond(fenecdb.Raw("t < now()")).
	Rows(ctx)
using FenecDb;
await db.From("events")
    .InsertAsync(new { name = "login", t = "2026-09-19T12:34:56Z" });

await db.From("events")
    .InsertAsync(new { name = "logout", t = 1758285296000 });

await db.From("events")
    .Where("t", ">=", "2026-01-01")
    .Where(Cond.Raw("t < now()"))
    .RowsAsync();
import FenecDB
try await db.from("events")
    .insert(["name": "login", "t": "2026-09-19T12:34:56Z"] as Value)

try await db.from("events")
    .insert(["name": "logout", "t": 1758285296000] as Value)

try await db.from("events")
    .where("t", ">=", "2026-01-01")
    .where(.raw("t < now()"))
    .rows()
import com.fenecdb.*
db.from("events")
    .insert(mapOf("name" to "login", "t" to "2026-09-19T12:34:56Z"))

db.from("events")
    .insert(mapOf("name" to "logout", "t" to 1758285296000))

db.from("events")
    .where("t", ">=", "2026-01-01")
    .where(Cond.raw("t < now()"))
    .rows()
import 'package:fenecdb/fenecdb.dart';
await db.from('events')
    .insert({'name': 'login', 't': '2026-09-19T12:34:56Z'});

await db.from('events').insert({'name': 'logout', 't': 1758285296000});

await db.from('events')
    .where('t', '>=', '2026-01-01')
    .where(Cond.raw('t < now()'))
    .rows();
sends
put events {name: $1, t: $2}
-- ["login", "2026-09-19T12:34:56Z"]
put events {name: $1, t: $2}
-- ["logout", 1758285296000]
get events where t >= $1 and t < now()
-- ["2026-01-01"]
Why timestamp is a type of its own

timestamp holds UTC epoch milliseconds as an i64. Writes accept both text and numbers; a text literal in a comparison is parsed.

The representation is always ISO-8601 (2026-09-19T12:34:56.789Z), in JSON and in FenecQL alike. The calendar conversion is integer arithmetic including leap-year and century rules — no table, no dependency.

It exists as a separate type rather than an alias over int because ResultSet does not carry the schema, so an alias would show every client a raw number.

Collation

Text sorts by its bytes unless you ask otherwise, which is wrong in every language. collate und sorts it the way people expect in any language, and collate tr in Turkish order.

get people order name collate und asc limit 20
get people order city collate tr desc, name collate tr asc
get people select city, count(*) group city order city collate und asc
import { connect } from '@fenecdb/web/client'
await db.from('people')
  .order('name', 'asc', { collate: 'und' })
  .limit(20)
  .rows();

await db.from('people')
  .order('city', 'desc', { collate: 'tr' })
  .order('name', 'asc', { collate: 'tr' })
  .rows();

await db.from('people')
  .select('city', 'count(*)')
  .group('city')
  .order('city', 'asc', { collate: 'und' })
  .rows();
from fenecdb import Client
(db.collection("people")
    .order("name", "asc", collate="und")
    .limit(20)
    .rows())

(db.collection("people")
    .order("city", "desc", collate="tr")
    .order("name", "asc", collate="tr")
    .rows())

(db.collection("people")
    .select("city", "count(*)")
    .group("city")
    .order("city", "asc", collate="und")
    .rows())
import fenecdb "github.com/fenecdb/fenecdb/integrations/go"
db.From("people").
	Order("name", "asc", fenecdb.Collate("und")).
	Limit(20).
	Rows(ctx)

db.From("people").
	Order("city", "desc", fenecdb.Collate("tr")).
	Order("name", "asc", fenecdb.Collate("tr")).
	Rows(ctx)

db.From("people").
	Select("city", "count(*)").
	Group("city").
	Order("city", "asc", fenecdb.Collate("und")).
	Rows(ctx)
using FenecDb;
await db.From("people")
    .Order("name", "asc", collate: "und")
    .Limit(20)
    .RowsAsync();

await db.From("people")
    .Order("city", "desc", collate: "tr")
    .Order("name", "asc", collate: "tr")
    .RowsAsync();

await db.From("people")
    .Select("city", "count(*)")
    .Group("city")
    .Order("city", "asc", collate: "und")
    .RowsAsync();
import FenecDB
try await db.from("people")
    .order("name", "asc", collate: "und")
    .limit(20)
    .rows()

try await db.from("people")
    .order("city", "desc", collate: "tr")
    .order("name", "asc", collate: "tr")
    .rows()

try await db.from("people")
    .select("city", "count(*)")
    .group("city")
    .order("city", "asc", collate: "und")
    .rows()
import com.fenecdb.*
db.from("people").order("name", "asc", collate = "und").limit(20).rows()

db.from("people")
    .order("city", "desc", collate = "tr")
    .order("name", "asc", collate = "tr")
    .rows()

db.from("people")
    .select("city", "count(*)")
    .group("city")
    .order("city", "asc", collate = "und")
    .rows()
import 'package:fenecdb/fenecdb.dart';
await db.from('people').order('name', 'asc', 'und').limit(20).rows();

await db.from('people')
    .order('city', 'desc', 'tr')
    .order('name', 'asc', 'tr')
    .rows();

await db.from('people')
    .select(['city', 'count(*)'])
    .group('city')
    .order('city', 'asc', 'und')
    .rows();
sends
get people order name collate und asc limit $1
-- [20]
get people order city collate tr desc, name collate tr asc
get people select city, count(*) group city order city collate und asc
create collection people (name text collate und @sorted, city text)
get people where name > $1 order name asc limit 20   -- the page after $1
import { connect } from '@fenecdb/web/client'
await db.from('people')
  .where('name', '>', 'Ada')
  .order('name')
  .limit(20)
  .rows();
from fenecdb import Client
(db.collection("people")
    .where("name", ">", "Ada")
    .order("name")
    .limit(20)
    .rows())
import fenecdb "github.com/fenecdb/fenecdb/integrations/go"
db.From("people").
	Where("name", ">", "Ada").
	Order("name", "asc").
	Limit(20).
	Rows(ctx)
using FenecDb;
await db.From("people")
    .Where("name", ">", "Ada")
    .Order("name")
    .Limit(20)
    .RowsAsync();
import FenecDB
try await db.from("people")
    .where("name", ">", "Ada")
    .order("name")
    .limit(20)
    .rows()
import com.fenecdb.*
db.from("people")
    .where("name", ">", "Ada")
    .order("name")
    .limit(20)
    .rows()
import 'package:fenecdb/fenecdb.dart';
await db.from('people')
    .where('name', '>', 'Ada')
    .order('name')
    .limit(20)
    .rows();
sends
get people where name > $1 order name asc limit $2
-- ["Ada", 20]
order nameorder name collate undorder name collate tr
Irmak, Zeynep, cem, Çağla, İlker, ılgazÇağla, cem, İlker, Irmak, ılgaz, Zeynepcem, Çağla, ılgaz, Irmak, İlker, Zeynep
Zoë, anna, Ζωή, 山田, Борис, Émileanna, Émile, Zoë, Ζωή, Борис, 山田anna, Émile, Zoë, Ζωή, Борис, 山田
  • Name it on one key in order, or declare it on a field so every comparison of that field uses it.
  • Equality still compares bytes, unless the field is nocase (Case-insensitive text).
  • In the browser, data for scripts other than Latin is fetched when first needed.
How collate und and tr order text, and what they cost and add to the module

Text is ordered by its bytes unless a key says otherwise, and the bytes are wrong in every language: every capitalised word comes before every lowercase one, an accented letter lands after z, and every script past ASCII falls where its code points do. collate und orders the key as Unicode's root collation does — CLDR's, which PostgreSQL calls und-x-icu — the order every language's starts from: each script in a run of its own, its letters in their order, accents and case deciding only between words whose letters are the same. A language whose letters sort otherwise is a tailoring over it, and collate tr is Turkish, PostgreSQL's tr-x-icu: ç after c, ğ after g, ı before i with I as its capital and İ as the capital of i, ö, ş and ü after o, s and u.

It compares a level at a time over the whole string, as ICU does: the letters, then their accents (kar < kâr < kara), then their case (ince < İnce), so an accent or a capital only decides between strings whose letters are the same. What all three leave equal goes by its bytes, as under a deterministic PostgreSQL collation — the order is total, and a page never changes with the plan. It applies wherever order does: the query's rows, a lookup's children, an aggregate's groups by their key or a min or max of text. A key that holds no text — a number, id, count(*) — refuses it rather than ignore it.

A field can be declared in a collation, name text collate und, and its text is in it wherever it compares: order without naming one, <, <=, > and >= in where, min and max, and a @sorted index over it, which holds that order and is walked for it. So a listing pages by the last name it showed — where name > $last order name limit 20 — and the index answers as the scan does, row for row. Equality stays the bytes', since the collation ties no two different strings. The collation is the field's: it is written with the schema into the file, and describe shows it. A @sorted field without one keeps its bytes' order, so a key collated in the query is sorted rather than walked, and a comparison in where compares bytes.

Over a million Turkish names (160 797 of them distinct) a comparison costs 33.5 ns in tr and 35.2 in und where the bytes cost 6.7, because it starts where two strings part — Ahmet Yılmaz against Ahmet Kaya is Y against K — which took it from 68 ns, and a Latin letter's weights are made once per collation and read, where looking each up through the tailoring and the root's tables took it to 41:

1 000 000 namesbytescollate trcollate undPostgreSQL, "C"PostgreSQL, "tr-x-icu"
a page, limit 2094.7 ms124 ms130 ms116 ms136 ms
all of them in order374 ms890 ms925 ms506 ms1 208 ms

fenecdb in process, PostgreSQL 17 by its own execution time with the sort in memory; both on one core (make collate-bench).

The weights are ICU's own, for every assigned code point. tools/collate/gen.py reads them out of ICU 76.1 into crates/fenec-core/src/collate/: the root's cut into chunks by script, and each tailoring as what it changes. Han ideographs sort in the radical-and-stroke order ICU's root gives them, those of the other planes among them; a Hangul syllable sorts as its jamo; and two or three characters ICU sorts as one sort as one — a letter and a mark it composes with (c and U+0327 is ç), a vowel sign written in two parts, a Thai or Lao vowel written before the consonant it follows in speech. An unassigned or private code point goes where ICU puts one. Checked against ICU itself over 1 250 000 random pairs in every script, of which five sort apart, and against Intl.Collator("und") over two thousand words in the browser module on every test run. What it does not do is ICU's normalisation: a mark that composes with its letter across another mark between them, or with marks out of their canonical order, sorts as a mark of its own — those five.

ICU numbers every ideograph and most alphabets' letters one by one — 133 535 primary weights, which no packed entry has the bits for — so consecutive code points that alone give consecutive weights share a rank and are told apart by their code points, and a run of them is held as one range; a table's words are written as differences in LEB128 besides: 154 KB for every script, 324 KB plain, which a native build carries whole — 161 KB of the command line's 1040.

The browser module carries the Latin chunk and fetches the others as a page first needs them, from collate/ beside the module (JavaScript). A statement that would compare text of a script whose data it has not got is refused, the chunk named, rather than answered in another order; a write is refused before it changes anything, and an image whose collated text needs a chunk loads once it is there — a @sorted index built without it would be out of order.

chunkbytesgzipbrotliholds
latin9.0 KB4.9 KB4.2 KBASCII, Latin-1, Extended-A to -C, Vietnamese, punctuation, currency; in the module
greek, cyrillic3.6, 2.1 KB1.6, 1.0 KB1.4, 0.9 KBGreek with polytonic, Cyrillic
middle-east7.3 KB3.6 KB3.3 KBArmenian, Hebrew, Arabic, Syriac, Thaana, N'Ko
indic3.0 KB1.6 KB1.5 KBDevanagari to Sinhala
southeast-asia8.5 KB2.2 KB2.2 KBThai, Lao, Tibetan, Myanmar, Khmer, Javanese …
east-asia8.2 KB4.6 KB4.2 KBHangul, kana, Bopomofo, Yi, full-width forms
han, han-ext31.1, 59.0 KB14.3, 27.3 KB12.7, 24.9 KBideographs of this plane, of planes 2 and 3
symbols6.9 KB2.8 KB2.6 KBarrows, mathematics, emoji, mathematical letters
other, smp5.7, 9.8 KB3.0, 5.0 KB2.8, 4.8 KBevery other script, of this plane and the next

The root and its chunks made the browser module 17.5 KB larger, 8.2 KB under gzip and 6.8 KB under brotli: the Latin chunk, the Turkish tailoring over it, and the code that reads them and has the others fetched. Every other chunk together is 145 KB, 67 KB gzip, 61 KB brotli. Their words are written as differences, which gzip and brotli take a third off: plain they were 102 and 90 KB. The Latin chunk alone comes out 0.3 KB larger under gzip that way, 0.9 KB under brotli, and 2.9 KB smaller in the module.

Case-insensitive text: nocase

A text field declared nocase compares by its case fold wherever it is compared, and keeps the text as it was written: an email under @unique takes Alice@x.com once, and finds it as alice@X.COM. It is PostgreSQL's citext, with Unicode's case folding where citext has lower().

create collection users (email text nocase @unique, name text collate tr nocase @sorted)
put users {email: "Alice@Example.com", name: "İsmail"}

get users where email = "alice@example.COM"    -- the row, its email as written
put users {email: "ALICE@example.com"}          -- refused: users.email is unique
put users {email: "alice@example.com"} if absent   -- passed over, and not counted
get users where name = "ismail"                 -- the row: İ is the capital of i in Turkish
get users select name order name asc limit 20   -- in Turkish order, case aside
import { connect } from '@fenecdb/web/client'
await db.from('users')
  .insert({ email: 'Alice@Example.com', name: 'İsmail' });

await db.from('users').where('email', 'alice@example.COM').rows();

await db.from('users').insert({ email: 'ALICE@example.com' });

await db.from('users')
  .insert({ email: 'alice@example.com' }, { ifAbsent: true });

await db.from('users').where('name', 'ismail').rows();

await db.from('users').select('name').order('name').limit(20).rows();
from fenecdb import Client
(db.collection("users")
    .insert({"email": "Alice@Example.com", "name": "İsmail"}))

db.collection("users").where("email", "alice@example.COM").rows()

db.collection("users").insert({"email": "ALICE@example.com"})

(db.collection("users")
    .insert({"email": "alice@example.com"}, if_absent=True))

db.collection("users").where("name", "ismail").rows()

db.collection("users").select("name").order("name").limit(20).rows()
import fenecdb "github.com/fenecdb/fenecdb/integrations/go"
db.From("users").
	Insert(ctx, fenecdb.D("email", "Alice@Example.com", "name", "İsmail"))

db.From("users").Where("email", "=", "alice@example.COM").Rows(ctx)

db.From("users").Insert(ctx, fenecdb.D("email", "ALICE@example.com"))

db.From("users").
	Insert(ctx, fenecdb.D("email", "alice@example.com"), fenecdb.IfAbsent())

db.From("users").Where("name", "=", "ismail").Rows(ctx)

db.From("users").Select("name").Order("name", "asc").Limit(20).Rows(ctx)
using FenecDb;
await db.From("users")
    .InsertAsync(new { email = "Alice@Example.com", name = "İsmail" });

await db.From("users").Where("email", "alice@example.COM").RowsAsync();

await db.From("users").InsertAsync(new { email = "ALICE@example.com" });

await db.From("users")
    .InsertAsync(new { email = "alice@example.com" }, ifAbsent: true);

await db.From("users").Where("name", "ismail").RowsAsync();

await db.From("users")
    .Select("name")
    .Order("name")
    .Limit(20)
    .RowsAsync();
import FenecDB
try await db.from("users")
    .insert(["email": "Alice@Example.com", "name": "İsmail"] as Value)

try await db.from("users").where("email", "alice@example.COM").rows()

try await db.from("users")
    .insert(["email": "ALICE@example.com"] as Value)

try await db.from("users")
    .insert(["email": "alice@example.com"] as Value, ifAbsent: true)

try await db.from("users").where("name", "ismail").rows()

try await db.from("users").select("name").order("name").limit(20).rows()
import com.fenecdb.*
db.from("users")
    .insert(mapOf("email" to "Alice@Example.com", "name" to "İsmail"))

db.from("users").where("email", "alice@example.COM").rows()

db.from("users").insert(mapOf("email" to "ALICE@example.com"))

db.from("users")
    .insert(mapOf("email" to "alice@example.com"), ifAbsent = true)

db.from("users").where("name", "ismail").rows()

db.from("users").select("name").order("name").limit(20).rows()
import 'package:fenecdb/fenecdb.dart';
await db.from('users')
    .insert({'email': 'Alice@Example.com', 'name': 'İsmail'});

await db.from('users').where('email', 'alice@example.COM').rows();

await db.from('users').insert({'email': 'ALICE@example.com'});

await db.from('users')
    .insert({'email': 'alice@example.com'}, ifAbsent: true);

await db.from('users').where('name', 'ismail').rows();

await db.from('users').select(['name']).order('name').limit(20).rows();
sends
put users {email: $1, name: $2}
-- ["Alice@Example.com", "İsmail"]
get users where email = $1
-- ["alice@example.COM"]
put users {email: $1}
-- ["ALICE@example.com"]
put users {email: $1} if absent
-- ["alice@example.com"]
get users where name = $1
-- ["ismail"]
get users select name order name asc limit $1
-- [20]
  • Everything that compares it folds it: =, !=, in, < and >, ~; @hash, @unique, @sorted and an index over several fields; order, group, count(distinct …), facet, min and max; a lookup on it; a scoped token's rules over it.
  • It is kept as written. A row reads back as it was put. A group or a facet value is one fold, shown as the first row holding it — in id order — wrote it.
  • Unicode's full case folding: ß, ẞ, SS and ss are one, a final ς is σ, fi is fi. In collate tr, Turkish's: I is the capital of ı, İ of i.
  • Case alone. Accents, width and how a text is composed are not case: résumé is not resume, A is a and not A, and é as one code point is not e and a combining accent.
  • A text field's. Not a list's or a json field's. Made nocase or no longer, a field is built again — the schema check's rebuild — which a @unique one refuses when two of its values would become one.
How the case fold is made and held

The fold is Unicode's full case folding, the C and F mappings of CaseFolding.txt, which Python's str.casefold() is too, and it is held to that at every code point (crates/fenec-core/tests/nocase.rs reads Python's own fold of each). Full rather than simple folding, which maps one code point to one: simple folding keeps straße apart from STRASSE, which ICU's case-insensitive comparison and every German reader find equal. A field in collate tr folds by Turkish's T mappings over it, as a Turkish reader expects:

nocasecollate tr nocase
Alice, ALICE, aliceoneone
straße, STRASSE, STRAẞEoneone
ΟΔΟΣ, οδος, οδοσoneone
ᏣᎳᎩ, ꮳꮃꭹ (Cherokee)oneone
ISTANBUL, istanbuloneapart
ISTANBUL, ıstanbulapartone
İstanbul, istanbulapart: İ folds to i and a combining dot, as its decomposed spelling doesone
ABC, abc / abcone / apartone / apart
résumé, resume; é, e+U+0301apartapart

A nocase field in a collation orders by the collation over the folds — collate tr nocase puts ılık before İnce and finds ince equal to the second — so its order and its equality agree: a collation tells every two different texts apart, and so it ties exactly the texts that fold to one, which come out in id order. Without a collation it orders by the folds' bytes. A query's collate orders it in another collation, still by its folds. ~, which ignores case on any text by lowercasing both sides, asks of a nocase field whether its fold holds the needle's: straße ~ "SS".

Its indexes hold the folds: a @hash or @unique bucket every spelling of one text, a @sorted index and an index over several fields their order, so an equality, an in, a range and an order … limit walk are answered as the scan answers them, row for row — a test holds every filter, order, group, facet and unique index over mixed-case data in ASCII, Turkish, German, Greek, Cherokee and full width to a twin collection holding Python's folds in plain fields. A lookup finds a child by its key as the child's field compares: a nocase one by the parent's value's fold. The schema writes the word into the file (a binary from before refuses it), describe, GET /_schema?as=fenecql and the declared schema carry it, a replica — a server's or a synced page's — compares as the primary does, and fenec import takes PostgreSQL's citext and SQLite's COLLATE NOCASE as it.

What folding costs, over 100 000 rows (make nocase-bench: a plain field holding one spelling and asked it, a nocase one holding a mix and asked another; names a third past ASCII):

100 000 rowsplainnocase
a put, asking @unique1 209 ns1 200 ns
an email by @unique1.08 µs1.16 µs
a city's rows counted by @hash3.15 µs3.27 µs
a name by the scan5.47 ms6.79 ms
a name's part by ~12.7 ms10.4 ms
an order by name, a page, by the scan8.48 ms16.2 ms
rows by city, group7.22 ms7.23 ms
a facet of names by the scan6.89 ms8.32 ms
in a list of 10 names, by the scan6.90 ms11.9 ms

An index lookup folds the one value it is asked. A scan folds a row only as far as it differs from the value — mostly its first character — with nothing allocated; ~ folds it whole, and still comes out quicker than lowercasing both sides. An order folds each row's key once, then sorts the folds; a @sorted index holds them folded and is walked instead. The fold is the standard library's lowercase mapping, and for the 297 code points whose fold is not their lowercase — 41 runs — its lowercase, uppercase and lowercase again: no table of its own beyond those runs. The browser module grew 11.5 KB, 5.0 KB gzip, 3.7 KB brotli — the engine's indexes, filters, groups, facets and orders 6.4 KB of it, the fold and its comparisons 2.1 — and a collection without a nocase field reads and writes as it did.

Changing a collection

A collection takes a field, lets one go, or renames one, and none of it rewrites a document: it is as quick over a million documents as over none.

alter collection orders add field note text
alter collection orders add field code text @unique
alter collection orders drop field note
alter collection orders rename field total to amount
  • A field added reads null in every document written before it, and cannot be required.
  • A field dropped is gone from every read and write, and its index with it. compact gives its space back.
  • A type does not change in place. Add a field of the new type, set it from the old one, and drop the old one.
What a field change does to stored documents

A document is its values one after another in field order, so what a change costs is what it does to that order. A field added goes last: a document written before it ends before the field's place, and reads null there. A rename is the schema alone. A field dropped leaves its place in the documents written before the drop — skipped on read, written as null after it, refused by name everywhere — and compact writes every document without it, copying each other value as its bytes stand. A field added under a dropped one's name is a new field: the old values stay in the dropped place, and the new field reads null until it is written. A change of type is the one thing that would rewrite every document under the write lock, and is not done.

Each is a write as a create index is: one of a block, put back by its ROLLBACK with the writes after it, seen by a subscriber as a schema change, applied by a replica, carried by the change stream (op: "alter", the documents after it read by the schema it made). In the file it is record kind 12, and a dropped place a nameless field of type tag 12, both of which a binary from before them refuses rather than read the fields after a dropped one a place early. The index of a field added is built over the documents there, every one of them null; a dropped field's goes, and a renamed one's goes with the name, a vector field's graph included.

Measured over a million rows in memory: an add, a rename and a drop take 0.025, 0.021 and 0.004 ms. Reading a field past where a document ends costs nothing a scan can measure: five scans of the million took 21.2 to 39.1 ms before the feature and 21.6 to 39.3 after, from 1.7% slower to 1.7% faster by the scan — as the same build with the reads written as before was, which makes it where the code lands, not what it does — and the browser module's scan of 20 000 rows 1.70 ms against 1.71. A compact that takes a dropped field out took 95 ms against 56 for one with none to take: it writes each document again rather than copy it. On a server it holds the write lock, where a compact otherwise runs beside the database.

Inspecting the database

collections
describe articles
compact articles

compact drops deleted data and rewrites the file.

What compact leaves in place

compact drops the dead records and rewrites the file. The indexes stand — the documents are the same ones, and every index is keyed by id — except a vector graph holding tombstones, which is rebuilt: nothing else takes a deleted or rewritten document's node out of it. See Limits.