Mobile and native apps
Keep the database in a file on the device: no server, no network, the same engine and file format as the server and the browser.
An iOS, macOS, Android or Flutter app opens a fenecdb file, writes to it
and asks it questions in FenecQL or through the query builder, with
near, match and lookup as anywhere
else. A live query hands the UI its rows again after every write to what it
reads.
| Swift | Kotlin and Java | Dart and Flutter | |
|---|---|---|---|
| install | SwiftPM: github.com/fenecdb/fenec, product FenecDB | com.fenecdb:fenecdb-android (the AAR), com.fenecdb:fenecdb (the JVM) | fenecdb_flutter, or fenecdb in Dart alone |
| platforms | iOS 15, macOS 12 | Android 5 (API 21): arm64-v8a, armeabi-v7a, x86_64; the JVM | iOS, Android, macOS |
| calls | async, off the caller's thread | suspend on Dispatchers.IO; ...Blocking for Java | Futures, on an isolate of the database's |
| live queries | LiveQuery (ObservableObject), LiveRows (@Observable), an AsyncThrowingStream | a Flow: Compose's collectAsState | a Stream: StreamBuilder |
| rows | Row, or any Decodable | Row, a Map with typed getters | Map<String, Object?> |
Details
Each binding calls the same native library, crates/fenec-ffi:
the engine built for the device, behind a C ABI of a few functions --
open, query, the change notice, sync, flush, checkpoint, close. A statement
goes in as text with its parameters as JSON, and its answer comes back as
the JSON the browser module answers with, from the same code
(fenec-abi), so a page and an app handed the same text get the
same bytes. A vector goes in as its f32 bytes beside the
JSON, never as digits. The same file can be a replica a server keeps in
step (Sync with a server), and an app can ask a server
with no file at all (Without a file).
A todo list
The file in the app's own directory, a collection made once, a list on screen that is a live query, and writes from the buttons. The list redraws itself: nothing calls it after a write.
import FenecDB
import SwiftUI
struct Todo: Codable, Sendable, FenecValue {
var id: Int?
var title: String
var done: Bool
}
@main struct TodosApp: App {
@State var db: Fenec?
var body: some Scene {
WindowGroup {
if let db { TodoList(db: db) } else { ProgressView().task { db = try? await open() } }
}
}
func open() async throws -> Fenec {
let dir = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true)
let db = try await Fenec.open(dir.appendingPathComponent("todos.fenec"))
try await db.execute("create collection if not exists todos (title text, done bool @hash)")
return db
}
}
struct TodoList: View {
let db: Fenec
@StateObject var open: LiveQuery<Todo>
init(db: Fenec) {
self.db = db
_open = StateObject(wrappedValue: LiveQuery(db, try! db.from("todos").where("done", false), as: Todo.self))
}
var body: some View {
List(open.rows, id: \.id) { todo in
Button(todo.title) {
Task { try await db.from("todos").where("id", todo.id!).update(["done": true] as Value) }
}
}
.toolbar {
Button("Add") { Task { try await db.from("todos").insert(Todo(title: "milk", done: false)) } }
}
}
}On iOS 17 and macOS 14, LiveRows is the same as an
@Observable, held in @State.
import com.fenecdb.*
class TodosModel(app: Application) : AndroidViewModel(app) {
val db = Fenec.open(File(app.filesDir, "todos.fenec").path) // in a background start-up, or openAsync
init {
db.executeBlocking("create collection if not exists todos (title text, done bool @hash)")
}
val open: Flow<List<Row>> = db.live(db.from("todos").where("done", false))
fun add(title: String) = viewModelScope.launch { db.from("todos").insert(mapOf("title" to title, "done" to false)) }
fun finish(id: Long) = viewModelScope.launch { db.from("todos").where("id", id).update(mapOf("done" to true)) }
override fun onCleared() = db.close()
}
@Composable
fun Todos(model: TodosModel) {
val open by model.open.collectAsState(emptyList())
LazyColumn {
items(open) { todo -> Text(todo.string("title") ?: "", Modifier.clickable { model.finish(todo.long("id")!!) }) }
}
Button(onClick = { model.add("milk") }) { Text("Add") }
}A live query is a conflated Flow: a collector that falls
behind sees the latest rows.
import 'package:fenecdb_flutter/fenecdb_flutter.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final dir = await getApplicationSupportDirectory();
final db = await Fenec.open('${dir.path}/todos.fenec');
await db.execute('create collection if not exists todos (title text, done bool @hash)');
runApp(MaterialApp(home: Todos(db)));
}
class Todos extends StatelessWidget {
final Fenec db;
const Todos(this.db, {super.key});
@override
Widget build(BuildContext context) => Scaffold(
body: StreamBuilder(
stream: db.live(db.from('todos').where('done', false)),
builder: (context, snap) => ListView(children: [
for (final todo in snap.data ?? const <Map<String, Object?>>[])
ListTile(
title: Text(todo['title'] as String),
onTap: () => db.from('todos').where('id', todo['id']).update({'done': true}),
),
]),
),
floatingActionButton: FloatingActionButton(
onPressed: () => db.from('todos').insert({'title': 'milk', 'done': false}),
child: const Icon(Icons.add),
),
);
}The plugin's example/ is this app, built for Android and
the iOS simulator on every change.
A fuller app, Notes -- search with match and
fuse, filters, a schema file, an add form -- is in the
repository for each: SwiftUI
(and a CLI), Jetpack
Compose (and a JVM CLI) and Flutter,
each built on every change and its headless part run.
The builder
The same query builder as JavaScript, Python, Go and .NET:
from, select, where with every
operator, near, match, fuse,
rerank, highlight, snippet,
facet, lookup, order,
limit, and the writes, each value a parameter and each name
checked. All seven make the same text of the same chain, to the byte, held
to one golden file; each language's spelling is on
Your language. A builder query also
knows the collections it reads, which is what a live query runs again for.
To see the text and parameters a chain builds, for logging or a test,
toFenecQL() returns them and runs nothing.
let near = try await db.from("notes")
.select("title")
.where("lang", "en")
.near("embed", embedding, ef: 64) // a [Float]
.limit(5)
.rows(as: Note.self)The schema in code
An app that makes its database declares it once, as
create collection and create index statements --
a schema.fenecql in the app's resources -- and hands it to
schema after each open. The engine compares the file with it:
what only adds (a collection, a field, an index) is made, the migrations
not yet recorded run first, in order, and are kept in
_migrations, all one block; a field the text lacks, a type or
an index changed, is refused with what resolves it, and nothing is written.
It is the check the browser's Fenec.open({ schema }) and the
server's /_schema run (Schema in
code).
let plan = try await db.schema(schemaText, migrations: [
"alter collection notes rename field title to name",
])val plan = db.schema(schemaText, migrations = listOf(
"alter collection notes rename field title to name",
))final plan = await db.schema(schemaText, migrations: [
'alter collection notes rename field title to name',
]);fenec types --lang swift|kotlin|dart schema.fenecql writes
a row of each collection as the language reads it: a Codable
struct, a data class with from(row), a class with
fromJson.
Live queries
A live query hands over its rows at once, and again after a write to a collection it reads. The writes of a burst -- several at once, a loop, a text of several statements -- run it once, a frame after the last of them lands. A write to another collection runs nothing, and a text that fails is put back whole and runs nothing either.
Details
The engine keeps a ring of the writes it landed, and
fenec_changes(since) names the collections written since a
cursor -- or answers "everything" when the cursor fell behind the ring or a
collection written since was dropped. A write asks for a look; the looks
asked for within a frame (16 ms) are one, taken once no write through the
database is under way, and each query that reads a collection named runs
again from scratch -- well under a millisecond over a local file, where
keeping track of which rows changed would cost more than the query. Knowing
costs a write nothing: the ring is asked once after a burst, and only by a
database holding a live query. It is how the browser's
Fenec.live works, with a frame in place of a microtask: an
app's writes run on other threads and come back one at a time.
A builder query reads its own collection and each lookup's; a
FenecQL text names its collections, or runs after every write.
Sync with a server
The todo list kept in step with a server, every device seeing every change: open the file as a replica of the rows you want. That is the one line that changes -- the list is still a live query of the file, and a tap still writes to it.
let db = try await Fenec.sync(url: "https://api.example.com", token: jwt,
shapes: [Shape("todos", key: "key")],
path: dir.appendingPathComponent("todos.fenec").path)val db = Fenec.sync(url = "https://api.example.com", token = jwt,
shapes = listOf(Shape("todos", key = "key")),
path = File(app.filesDir, "todos.fenec").path)final db = await Fenec.openSynced(url: 'https://api.example.com', token: jwt,
shapes: [Shape('todos', key: 'key')],
path: '${dir.path}/todos.fenec');The collection is made on the server, with a key the app's inserts are
matched by -- create collection todos (key text @unique, title text,
done bool @hash) -- and comes to the replica from there, so the
create collection line goes. Dart's is
openSynced: sync() is the fsync, and Dart has no
static and instance member of one name.
- Reads are the file's. A query, a live query and the builder read the replica: no network, and the app works offline.
- A shape is a set. The rows of one collection a filter on the
server picks,
Shape("todos", where: ["owner": me]), in the JS sync layer's form; one shape a collection. Order and limits are a query's, not a shape's. - A write shows at once. It is applied to the replica as it is made, and sent. An insert gets a temporary id and a key, and the server's copy takes its place when it arrives.
- A refused write is put back. When the server refuses it -- a
409for a unique value held, a403from a token's rules, a507from a tenant's quota of bytes or rows (quotanames it) -- the replica is put back as it was and the app is told:db.replica.refusals. - Offline, writes wait. A write made while the server cannot be reached waits in the file, and goes once it can, in order, once each. An app closed meanwhile sends it when it opens again.
- It catches up from where it stopped, after a dropped connection, a server restart or a week in a drawer -- or is sent the whole shape again when the server's ring of changes no longer reaches back that far.
| Swift | Kotlin | Dart | |
|---|---|---|---|
| open | Fenec.sync(url:token:shapes:path:) | Fenec.sync(url, token, shapes, path) | Fenec.openSynced(url:, token:, shapes:, path:) |
| status | replica.status, statuses() (an AsyncStream) | replica.status (a StateFlow) | replica.status, statuses (a Stream) |
| first seed, writes answered | ready(), pushed() | ||
| refused writes | refusals() | refusals (a SharedFlow) | refusals |
| a new token | setToken(jwt), or a tokenProvider asked on a 401 | ||
| the network | setOnline(bool), resume() | ||
| the server itself | replica.remote (below) | ||
The status says online, offline or
catchingUp (connecting, seeding, or sending the writes left
from before), how many writes the server has not answered, and the last
error.
Details
What to ask, what an answer means and what is written are one state
machine in Rust (fenec_abi::sync), with no network of its own:
a binding feeds it what happened -- a request's answer, a stream's bytes, a
timer, the network, a new token -- and does what it hands back: make this
request, open this stream, wait this long. So the logic exists once, with
one suite of tests driving it through scripted server event sequences, and
each binding's loop is a page over its platform's HTTP client. A write to a
shape's collection through run, the builder or
execute goes to it on its own: applied to the replica, what
puts it back kept, and the write queued with an idempotency key -- all one
block in the file. Writes go one at a time, in order, each under its key, so
a write whose answer was lost is sent again and answered as the first time,
not made again. A server's answer of 0 (no answer),
408, 429 or 5xx is tried again with
backoff, 250 ms doubling to 15 s, or after the wait a tenant's quota (to
the millisecond, retry_after_ms), a move or a failover
(retry_after) names where that is longer; a
401 asks for a token; a 507 naming a tenant's
quota, and any other 4xx, is a refusal, and the writes behind
it go on
(why). The status's
error names the quota too: a stream refused by the quota of streams leaves
the replica offline, as the network does. A change applies in one block with the
shape's cursor, so a crash leaves the two agreeing. A change saying the
server's schema changed, or rows with a field the replica has not got, has
the collection made again from GET /collections -- its rows kept
in the fields that are left -- and the whole shape sent again.
The file holds three collections of the sync's own beside the
replica's: _sync_shapes (each shape's cursor),
_sync_queue (the writes not yet answered) and
_sync_temps (rows an insert made under a temporary id, until
the server's copy). A collection with no shape is the app's own, written
and read as in a file of its own; a synced collection's schema is the
server's, and create index or drop over it is
refused. A change of one row costs the replica 18.4 µs against 7.1 for the
same put straight into the engine (the cursor is written in the same
block), rows in changes of 100 cost 3.8 µs either way, and a seed of 10 000
rows 72 ms (make sync-bench).
The browser's sync (Sync) is
written apart -- this core in the browser module was 21 KB brotli more --
and the two are held to one script: integrations/sync-scenarios.json,
71 scenarios of shapes, writes, answers and server events, each step's
replica, queue and requests stated once and run against both. What is left
between them is the platforms':
- A write returns once it is applied and kept:
awaiton a button would hang offline. A refusal comes onrefusals, andpushed()waits for the answers. In the browser the write's promise settles with the server's answer, which a page need not await;onRefusedandpushed()are there too. - A text of several writes is one write, sent as one
/batch; the browser's builder writes one statement at a time, andbatch()makes several one write the same way. - There is one replica a file, held by one process, so no leader is
elected among tabs; the file is the persistence, where a page stores its
replica in IndexedDB (
persist) or anopenFile. - Every collation is in the library; the browser module fetches a script's chunk the first time a row needs it.
TLS on devices
iOS (App Transport Security) and Android refuse plain http://
by default, and so should you: put TLS in front of
fenec-server. Caddy does it in three lines, certificate
included:
api.example.com {
reverse_proxy 127.0.0.1:8080
}Each binding makes its requests with the platform's client --
URLSession, HttpURLConnection,
dart:io's HttpClient -- so the system's trust
store, proxies and pinning apply. An Android app declares the
INTERNET permission and a sandboxed macOS app the
com.apple.security.network.client entitlement, as for any
request. During development, an emulator reaches
a server on the machine at plain http:// once allowed: iOS's
NSAllowsLocalNetworking, Android's
network_security_config with cleartextTrafficPermitted
for 10.0.2.2.
Details
The change stream is Server-Sent Events: a response that does not end.
Caddy passes text/event-stream through as it comes; behind
nginx, the server's X-Accel-Buffering: no does the same. A
proxy that closes an idle connection sooner than the server's keepalive
(a comment every 20 s) cuts the stream, which then opens again from its
cursor -- harmless, a round trip each time. fenecdb's own HTTP client, the
server's, speaks no TLS and is not used on a device.
Battery and the background
A replica holds one connection a shape open while the app runs, quiet
but for the keepalive. iOS suspends an app soon after it leaves the
screen, and Android stops one's network in Doze: the streams are cut, and
nothing is lost -- a write waits in the file, a change waits on the server.
As the app comes back, call resume() (on
scenePhase == .active, onStart,
AppLifecycleState.resumed): the streams open at once from their
cursors, the waiting writes go first, and the live queries run again as the
changes land.
Details
setOnline(false) as the app leaves the screen closes the
streams at once rather than when the system cuts them, and spares the radio
the keepalive. Backoff after a failure doubles from 250 ms to 15 s, with
jitter, so a server back up is not met by every device at once;
resume() and setOnline(true) forget it. Nothing
here runs in the background: a write made just before suspension is sent on
the next resume, or by an app's own background task (iOS's
BGAppRefreshTask, Android's WorkManager) that opens the replica
and waits for pushed(). A replica away longer than the server's
ring of changes reaches back (--changes, 4 096 writes by
default) is sent its shapes whole on return.
Without a file: the server alone
An app that keeps nothing on the device asks the server directly:
Fenec.connect makes every query a request, through the same
builder, as connect does in JavaScript.
let db = Fenec.connect(url: "https://api.example.com", token: jwt)
let open = try await db.from("todos").where("done", false).rows()Kotlin's is Fenec.connect(url, token = jwt), Dart's
Fenec.connect(url, token: jwt); a replica's
replica.remote is the same, for what the replica does not
hold. Live queries are the file's alone.
Several writes that must land together are one batch
(POST /batch): the builder's toInsert,
toUpdate and toDelete under one write lock, all
of them or none. One that stops it is an error naming its place
(at, from 0) and the HTTP status -- 412, code
unmet, for a write whose require was not met. A
retry after a timeout cannot tell whether the write ran, so a write that
must not run twice goes under an idempotency key: the second answer is the
first one kept (replayed), and the key sent with another
request is refused (422).
let accounts = try db.from("accounts")
let done = try await db.batch([
try accounts.where("id", from).where("balance", ">=", 10).toUpdate(["balance": .inc(-10)] as Value, require: 1),
try accounts.where("id", to).toUpdate(["balance": .inc(10)] as Value, require: 1),
], idempotencyKey: transferID)
// done.results, done.seq, done.replayed
try await db.withIdempotencyKey(orderID).from("orders").insert(order)Kotlin's is db.batch(listOf(...), idempotencyKey = id) and
runList(text, params, idempotencyKey = id), Dart's
db.batch([...], idempotencyKey: id); all three have
withIdempotencyKey(key), a copy whose every write -- the
builder's too -- carries the key, and seq, the change the last
write left the server at. withWait -- Swift's in seconds,
Kotlin's in milliseconds, Dart's a Duration -- is a copy whose
sets and dels the server holds while they write
nothing, a worker's claim waiting there for a job
(Waiting for a job); cancelling the task or
the coroutine gives it up.
Where the file lives
Anywhere the app may write. On iOS the app's Application Support
directory, which is backed up and never shown in the Files app; on Android
context.filesDir, or noBackupFilesDir to keep it
out of a backup; in Flutter path_provider's
getApplicationSupportDirectory(). Beside the file the library
keeps <name>.lock while it is open.
- Open a file once. A second open of the same file, in the app or
in another process -- an app extension sharing the app group's container
-- is refused (
locked): two databases over one file corrupt it. - iOS file protection. A file under the
completeprotection class cannot be read while the device is locked, and a page of a mapped file read then is the app's end, not an error. Keep the default class (complete until first user authentication), or open the file with.inMemory. - The file compacts itself. An update writes the row again at the
file's end; a thread of the library's looks every 5 s and compacts the
file beside your calls once half of it is versions updates and deletes
left behind, and at least 64 MB, so a file you keep updating stays under
about twice what it holds -- sooner once it reaches three quarters of
the memory the device has for its pages, so its reads stay in
memory. Open with
.noAutoCompact(NO_AUTO_COMPACT,noAutoCompact) to compact only when you runcompact.
Details
The file is mapped, as a server maps its own: the documents stay in it
and the app holds only what it derived from them, the indexes and the
graph. The mapping takes address space past the file's end -- a gigabyte at
least -- for the writes to grow into; where the system refuses that much, as
an iOS app's address space may, the file alone is mapped and mapped again
as it grows. A 32-bit Android device (armeabi-v7a) reads the file into
memory. The lock is a file of its own, held with a record lock
(fcntl), which belongs to the process: a checkpoint renames a
new file over the database, which a lock on the database itself would not
survive, and an flock belongs to the open file, which a child
process started meanwhile shares until it runs its program -- a close and
an open again at that moment were refused.
Durability
Every write is on disk before the call returns: written and fsynced, outside the database's lock, so reads go on meanwhile and writes arriving together share one fsync. On a Mac that fsync is about 4 ms. For a burst of writes, open the file without it and sync when they are done:
| what it does | what it survives | |
|---|---|---|
| a write, by default | written and fsynced before it returns | the device losing power |
noSync, then sync() | writes wait in a buffer, then one fsync | the device losing power, once synced |
flush() | the buffer handed to the system, no fsync: microseconds | the app being killed |
checkpoint() | the file written anew, graphs and all | -- (the next open links nothing) |
close() | saves the graphs, syncs, lets the file go | everything |
A phone ends an app in the background without a word, so an app opened
with noSync calls flush() or sync()
as it goes there -- scenePhase turning to
.background, onStop, AppLifecycleState.paused.
Details
A write the disk refuses stops the database's writes, as on a server:
every later write and sync is refused with io until the file
is opened again, and reads go on. The vectors a database holds are linked
into its graph as they are written; the open links those written since the
graph was last saved. close() saves a graph once the file has
grown three times the graph's record since its last save, so an app that
opens and closes often does not grow its file by a graph each time;
checkpoint() saves everything, and an open after it links
nothing.
The hash, text, ordered and sparse indexes are never in the file: an
open leaves each for the first statement that reads it, which builds it
first -- a @text index of 100 000 products took that search
141 ms. warm() builds them off the main thread as the app
starts, each under the read lock on its own so reads go on beside it, and
answers how many it built; warm(["docs", "orders.status"])
builds those collections' and fields' alone (Kotlin
warm(listOf(...)), Dart warm([...])).
Threads
One database is safe to use from every thread, task or coroutine at
once: a read runs beside other reads, a write takes the database to itself,
a long read — an aggregate, a scan, a text of several reads — pins what it
reads and lets writes land beside it, as a server's does, and a lone
create index or compact is built beside the
database while reads and writes go on. Every binding makes its calls
off the main thread, since any of them may wait -- for the lock, an fsync, a
whole open.
Sizes and speed
| the native library, stripped | size |
|---|---|
| macOS, Apple silicon | 1.68 MB |
| macOS, Intel | 1.77 MB |
| Linux arm64, with the JNI functions | 1.71 MB |
| Linux x86-64, with the JNI functions | 1.88 MB |
The sync with a server is 165 to 198 KB of each. iOS's and Android's are measured by CI for every change, in the run's summary. An app ships the code it links: the XCFramework's static library is the objects before the app's linker takes what it uses.
| 20 000 × 128, Apple M1 | through the library | fenec-server's handler, in process |
|---|---|---|
| open, checkpointed | 2.9 ms | 2.6 ms |
| a put with its vector, buffered | 4.6 µs | 23.2 µs |
| a put with its vector, fsynced | 4.0 ms | 4.0 ms |
near, limit 10 | 97.8 µs | 151.4 µs |
Details
make ffi-bench makes the file through the library and
opens it both ways: the library as an app opens it, the server's handler as
fenec-server serves it, the request built in process with no
socket. The medians of 1 000 calls each. The library is handed a vector as
its f32 bytes, as the bindings hand it; as JSON a buffered put
takes 23.3 µs, the server's time, most of it the vector's digits.
The C ABI
Any language with a C FFI can call the library: the header is
crates/fenec-ffi/include/fenec.h, and make ffi
builds the library for this machine, or TARGET=... for another.
Every call returns a code -- 0, an error's kind, or the boundary's own:
FENEC_MISUSE, FENEC_LOCKED,
FENEC_PANIC -- and writes its answer or its error as
NUL-terminated JSON, freed with fenec_free_string.
Details
A handle is a number, not a pointer: a handle used after its close finds
nothing and is refused, where a pointer would reach freed memory, and a
call holds its database for as long as it runs whatever closes meanwhile.
A panic never crosses the boundary: each call runs under
catch_unwind and answers FENEC_PANIC with its
message, which is why the library is built to unwind, not to abort. The
Kotlin binding reaches the same calls through JNI functions the library
carries itself (its jni feature): each call answers one
byte[], its code then its JSON, and text goes in as UTF-8
bytes, since JNI's own strings write an emoji as two halves.