aquabase 0.7.1
aquabase: ^0.7.1 copied to clipboard
Flutter SDK for Aquabase — encrypted real-time database with offline sync, tags, and binary transport.
Aquabase Flutter SDK #
Encrypted real-time database for Flutter. Offline-first with automatic cloud sync, binary transport, and zero code generation.
Quick Start #
Initialize Aquabase in a single file to prevent redundant connections:
lib/aquabase.dart
export 'package:aquabase/aquabase.dart';
import 'package:aquabase/aquabase.dart';
// 1. Initialize once (e.g. inside main() before runApp)
Future<void> initAquabase() async {
await Aquabase.init(
url: 'YOUR_SERVER_URL', // optional (defaults to Aquabase cloud)
projectId: 'YOUR_PROJECT_ID',
apiKey: 'YOUR_API_KEY',
);
}
// 2. Extract sub-modules globally using the singleton
final app = Aquabase.instance;
final auth = app.auth;
final storage = app.storage;
final logs = app.logs;
final e2e = app.e2e;
Usage anywhere in your app:
import 'package:your_app/services/aquabase.dart';
// Database
await app.collection('users').doc('u1').set({'name': 'Ana', 'age': 25});
final user = await app.collection('users').doc('u1').get();
// Auth
final authResult = await auth.login('user@example.com', 'secret123');
// The complete session is installed and persisted automatically.
print(authResult.accessToken);
// Storage
// Buckets auto-create on first upload — no provisioning needed.
await storage.upload('avatars', 'photo.jpg', bytes);
How It Works #
- All database reads/writes go to the local cache — instant, no network latency
- When online, changes sync to the server in background
- When offline, failed ops queue to disk automatically
- On reconnect, queued operations flush to the server automatically
This is the database. app.storage replicates downloads locally but uploads
straight to the server — see Storage.
The offline backlog accepts up to 10,000 operations or 64 MiB by default. A write that would exceed either limit fails without partially queuing a bulk operation: bulkSet/bulkDelete throw BacklogOverflowException, whose dropped property reports how many writes were left out.
Collections #
A collection name is 1–128 characters of A-Za-z0-9._- — the same rule the
server enforces, applied to local collections too.
A document ID may carry / segments: A-Za-z0-9._- per segment, none of them
empty, . or .., and the whole collection/docId key capped at 128
characters. The SDK applies the server's own rule and throws an
ArgumentError before the write reaches the local store, so a malformed ID
never turns into a silent write error a round-trip later.
Those segments are what a rule variable binds to. A key of
courses/{uid}/{courseId} is covered by
match /courses/{uid} {
allow read, write: self
}
because a rule matches by segment prefix: {uid} captures the second segment
and self compares it to the signed-in UID. A composite ID joined by anything
else — '${uid}_$pushId' — is a single segment, so nothing binds the UID
inside it and self has nothing to compare; that shape can only be gated by
auth or role(...).
Each serialized database value is limited to 1 MiB (maxValueBytes). The
SDK rejects larger values before caching or syncing them; use app.storage for
files or larger binary content.
With the local cache enabled (the default), writes are local-first: await set()
resolves once the value is in the local store, and the server round-trip
continues in the background — retried through the offline queue when the
connection is down. bulkSet is durable: it waits for the server to
acknowledge every chunk (with a bounded in-flight window) and throws when the
server rejects a batch it could not leave in the offline queue. If the offline
backlog is full, it throws BacklogOverflowException with dropped set to
how many writes were left out.
Listen to app.writeErrors to react to a write that did not go through;
without a listener the SDK logs the failure in debug rather than dropping it.
The SDK evaluates the project's rules locally, so a write the rules forbid
never reaches the local store — online or offline — and writeErrors emits a
WsDeniedException immediately, with no round-trip and nothing queued. A write
allowed by the snapshot the client holds still goes to the server, which remains
the authority: if the rules changed meanwhile, it rejects the write, the local
copy is reverted and writeErrors reports it. Under persistence: false there is no queue to
fall back on, so set/delete/bulkSet await the server and throw at the call
site instead.
final users = app.collection('users');
await users.doc('u1').set({'name': 'Ana'});
final user = await users.doc('u1').get();
await users.doc('u1').delete();
// Partial write: named fields only, `null` drops one. False if the doc does
// not exist — `update` never creates.
await users.doc('u1').update({'age': 26, 'nickname': null});
users.doc('u1').id; // 'u1'
// Evict from the local cache without deleting on the server
await users.doc('u1').delete(localOnly: true);
// Auto-generated ID
final id = await users.add({'name': 'Carlos', 'age': 30});
print(id); // e.g. 'aB3xK9mQ7pR2wT4vN1'
// Scope generated IDs under a prefix (groups related docs by key prefix)
final msgId = await chats.add(msg, idPrefix: '${chatId}_');
Bulk Operations #
await users.bulkSet({
'u1': {'name': 'Ana'},
'u2': {'name': 'Luis'},
'u3': {'name': 'Carlos'},
});
// Map<String, DocData> keyed by id, not a list. An id with no document is
// absent from the map rather than present as null.
final found = await users.bulkGet(['u1', 'u2', 'u3']);
for (final entry in found.entries) print('${entry.key} ${entry.value}');
await users.bulkDelete(['u1', 'u2', 'u3']);
Real-Time #
Watch a document or a query for changes — emits current data immediately, then on every update from any device.
// Watch a single document
final sub = users.watch('u1').listen((user) {
if (user != null) renderUser(user);
});
// Watch a query
final sub2 = users.where('status', eq: 'active').watch().listen((results) {
for (final d in results.docs) {
print('${d.id}: ${d.data}');
}
});
// Stop watching
sub.cancel();
sub2.cancel();
UI lifecycle (optional): For screens users revisit frequently, keep the stream subscription and last state in a longer-lived controller/provider, or preserve the page with
AutomaticKeepAliveClientMixin. The active watcher continues receiving cloud updates while the page is hidden, avoiding a fresh loading state on every rebuild. Cancel the subscription when its owning scope is actually disposed.
Reads #
get() reads from the local cache and falls back to the server on miss, caching the result. Concurrent reads for the same key share a single round-trip.
Every read returns a DocData: the decoded map — every Map member still
works — plus typed accessors that fall back when a field is absent, null or
written with another type. Nothing throws on a shape mismatch.
final user = await users.doc('u1').get();
if (user == null) return;
final apellidos = user.text('apellidos'); // '' if absent
final createdAt = user.integer('createdAt'); // int, however it was written
final grupos = user.texts('grupos'); // [] if absent
final ciudad = user.object('perfil')?.text('ciudad');
final activo = user.boolean('activo', true); // explicit fallback
final tel = user.value<String>('tel'); // null instead of a fallback
final pesos = user.list<num>('pesos'); // elements that are num
final raw = user.raw; // the map itself
A query answers with docs: its documents in plan order, each carrying its id —
the id is the key a result is stored under, not a field of the document.
final results = await schools.where('code', eq: code).get();
for (final school in results.docs) {
print('${school.id}: ${school.data.text('name')}');
}
results.docs.length; // how many
results.docs.map((d) => d.id); // the ids alone
// Lookup by id, when a result set is queried more than once:
final byId = {for (final d in results.docs) d.id: d.data};
Indexes #
Los índices se declaran en database.indexes.toml y se aplican con el CLI, el único camino que lleva autoridad de owner — el SDK cliente nunca registra uno. Una vez desplegado el campo, cada set() genera sus tags.
aquabase indexes add attendances date type # declarar en el manifiesto
aquabase indexes deploy # aplicar: un backfill por colección
El schema es del proyecto, no de la app: cualquier app del mismo proyecto escribe con los tags de todos los campos desplegados desde el primer set(), sin coordinación manual.
Una query sobre un campo no declarado lanza IndexRequiredException, cuyo command es la línea aquabase indexes add exacta a ejecutar. Mientras el backfill corre, las queries responden con lo disponible en caché (snapshot.state == QueryState.syncing); si falla, lanzan IndexFailedException.
Valores únicos #
Una colección puede declarar grupos de campos cuyo valor no debe repetirse. Cada campo del grupo tiene que estar indexado; un grupo de un campo es una columna única, uno de varios es único sobre la combinación.
[people]
unique = [["dna"]] # un documento por dna
[[people.indexes]]
fields = [{ name = "dna", kind = "eq" }]
El servidor lo resuelve al escribir, contra el índice que ya tiene en memoria: sin tabla extra y sin lectura extra. Tres reglas deciden:
- Un documento que reescribe su propio valor pasa. Solo otro documento que lo tenga es conflicto.
- Un grupo con un campo ausente o vacío queda exento, como
NULLen SQL. En un grupo de varios campos basta que falte uno para eximir al grupo entero. - Declarar o retirar un grupo reconstruye el índice de la colección. Si los
datos ya tienen duplicados el job termina
failedy la restricción no se publica — los campos siguen indexados, solo se descarta el grupo. Mientras esa reconstrucción corre, las escrituras a la colección se rechazan.
La garantía es del servidor y a posteriori: dos dispositivos offline aceptan el
mismo valor localmente, y el segundo se revierte al llegar al servidor. Un
set() rechazado reporta UniqueException por app.writeErrors con la clave
que retiene el valor; la copia local vuelve a lo que tiene el servidor en vez de
quedar en la cola, porque reintentar nunca puede funcionar.
Procedencia #
Toda query se resuelve contra el dispositivo cuando el servidor no está al alcance — el índice local se deriva bajo demanda desde la caché, así que operadores, orden y paginación se comportan igual sin conexión. Lo que cambia es qué abarca la respuesta, y cada snapshot lo dice:
source |
partial |
Significado |
|---|---|---|
QuerySource.server |
false |
El servidor respondió sobre la colección completa |
QuerySource.cache |
true |
Respondió el dispositivo: completa solo para lo que ya tiene |
QuerySource.local |
false |
Colección local: true — el dispositivo es la autoridad |
final snapshot = await attendances.where('type', eq: 'entrada').get();
if (snapshot.partial) { /* lo que hay en el dispositivo, no la colección */ }
Igualdad #
final attendances = app.collection('attendances');
// Un día exacto
final results = await attendances
.where('date', eq: '2026-03-18')
.where('type', eq: 'entrada')
.get();
// Watch en tiempo real
attendances
.where('date', eq: '2026-03-18')
.watch()
.listen((results) => print(results.docs.length));
Rangos #
Usa from/to para rangos inclusivos (el caso más común). El SDK selecciona automáticamente el índice BTreeMap.
final scores = app.collection('scores');
// Scores entre 80 y 100
final high = await scores
.where('score', from: 80, to: 100)
.get();
// Asistencias del mes de marzo
final march = await attendances
.where('date', from: '2026-03-01', to: '2026-03-31')
.where('studentId', eq: 'stu_001') // filtro adicional de igualdad
.get();
// Un solo día (usa eq, no range)
final today = await attendances
.where('date', eq: '2026-03-20')
.get();
Todos los operadores disponibles:
| Parámetro | Tipo | Descripción |
|---|---|---|
eq |
cualquier | Igualdad exacta |
isIn |
List | Match a cualquiera de N valores (OR) |
from |
num / String | Inicio de rango (≥ inclusivo) |
to |
num / String | Fin de rango (≤ inclusivo) |
gt |
num / String | Mayor que (> exclusivo) |
lt |
num / String | Menor que (< exclusivo) |
isIn (match por múltiples valores) #
isIn matchea cualquiera de los valores de la lista. Combínalo con eq para acotar el conjunto base.
final tickets = app.collection('tickets');
// Tickets en cualquiera de estos estados
final open = await tickets
.where('status', isIn: ['active', 'pending', 'draft'])
.get();
// Tickets de un owner en cualquiera de estos estados (base eq + isIn)
final mine = await tickets
.where('owner', eq: 'u1')
.where('status', isIn: ['active', 'pending'])
.get();
Reglas:
- Un solo
isInpor query. No se combina confrom/to/gt/lt. - Lista vacía → resultado vacío sin tocar el server.
- Un solo valor se fusiona como
eq. - Los valores se deduplican. Cap:
64valores.
count y exists #
Ambos se resuelven en el servidor como una operación de bitmap, sin hidratar documentos.
final open = await tickets
.where('owner', eq: 'u1')
.where('status', isIn: ['active', 'pending'])
.count();
final hasOpen = await tickets
.where('owner', eq: 'u1')
.where('status', isIn: ['active', 'pending'])
.exists();
count devuelve int. exists cortocircuita al primer match. Ambos caen al dispositivo como cualquier otra query, pero un número pelado no lleva procedencia: consulta app.isConnected cuando importe distinguir "ninguno en la colección" de "ninguno en este dispositivo".
Ordenamiento #
orderBy() recorre el índice range del campo en el servidor, así que un limit se queda con las primeras filas de ese orden: orderBy(...).get(limit: 10) transfiere diez documentos, no todo el resultado.
// Los scores más altos de una clase
final top = await scores
.where('classId', eq: 'math_101')
.orderBy('score', descending: true)
.get(limit: 10);
// Los diez más recientes, sin ningún filtro
final latest = await posts.orderBy('createdAt', descending: true).get(limit: 10);
Reglas:
- El campo de orden necesita un índice
range(aquabase indexes add scores score:range). - Debe ser el campo que filtra la cláusula de rango, si la query tiene una. No se combina con
isInni con uneqsobre ese mismo campo. - Solo se ordenan los valores que el índice guarda: presentes, no vacíos y de como máximo 1 KiB codificados. Los números van antes que los strings.
Datos históricos: Los documentos escritos antes de registrar un campo como
rangese re-indexan automáticamente; las queries sobre ese campo esperan a que el backfill termine antes de devolver resultados.
Pagination #
Paginación basada en cursores para recorrer datasets grandes de forma eficiente. Cada página cuesta O(page_size) — la página 100 es tan rápida como la página 1.
final users = app.collection('users');
// Una sola página
final page1 = await users
.where('status', eq: 'active')
.fetchPage(50);
print(page1.docs); // List<DocEntry>
// Siguiente página usando nextCursor
final page2 = await users
.where('status', eq: 'active')
.fetchPage(50, page1.nextCursor);
// page2.nextCursor == null cuando no hay más páginas
// Iterar todas las páginas automáticamente
await for (final page in users.where('status', eq: 'active').paginate(50)) {
for (final d in page.docs) print('${d.id}: ${d.data}');
}
Funciona con range queries también:
await for (final page in scores.where('score', from: 80, to: 100).paginate(20)) {
// procesar página
}
Document References #
Cross-document links are plain strings of the form 'collection/docId'. Read
the value via doc.path and assign it to any field. The policy applied on
delete of the target lives in the schema, not in the data:
final posts = app.collection('posts');
final comments = app.collection('comments');
final p1 = posts.doc('p1');
await comments.doc('c1').set({
'text': 'Nice post',
'postId': p1.path, // 'posts/p1'
});
Declare the policy once in database.indexes.toml and deploy it:
[[comments.indexes]]
fields = [{ name = "postId", kind = "ref_cascade" }]
[[invoices.indexes]]
fields = [{ name = "customerId", kind = "ref_restrict" }]
[[products.indexes]]
fields = [{ name = "categoryId", kind = "ref_setnull" }]
| Policy | Effect ondelete(target) |
|---|---|
ref_restrict |
Aborts withRefGuardException listing dependents. |
ref_cascade |
Deletes every dependent (recursive, capped at depth 8 / 10 000 docs). |
ref_setnull |
Removes the field from each dependent. |
Indexes are stored exactly like an eq field — the only extra work happens on
the (rare) target delete.
try {
await posts.doc('p1').delete();
} on RefGuardException catch (e) {
for (final d in e.dependents) {
print('${d.from}.${d.field} → ${d.policy}');
}
}
// Inspect dependents proactively
final refs = await posts.doc('p1').incomingRefs();
Local Collections #
Same API as any collection, but data stays on the device — no server sync. Useful when you need a local database that works alongside your cloud collections.
final students = app.collection('_students', local: true);
// Same API as any collection
await students.doc('s1').set({'name': 'Ana', 'age': 12, 'grade': '6A'});
final student = await students.doc('s1').get();
await students.doc('s1').delete();
await students.bulkSet({
's1': {'name': 'Ana', 'age': 12},
's2': {'name': 'Luis', 'age': 13},
});
// Live too: a local write re-emits only to the watchers it actually affects.
final sub = students.where('grade', eq: '6A').watch().listen(render);
where() needs no declared index here. A synced collection gates every query
on an index the server has published (see Indexes); a local one is
answered from the device's own store, so any field is queryable with nothing
deployed.
A collection name cannot be both local and remote simultaneously.
Pure local mode: Omit
apiKeyfromAquabase.init()to run as a local-only database — no server connection, all collections must uselocal: true. Or passlocalOnly: trueto make everycollection(name)local automatically, even with anapiKeyset; an explicitlocal: falsethen throws.
final app = await Aquabase.init(projectId: 'my-app', localOnly: true);
// Every collection is local; no connection to Aquabase Cloud is opened.
final notes = app.collection('notes');
localOnlyis independent ofpersistence: with the defaultpersistence: truethe data is durable; addpersistence: falseto keep it in RAM only.
A local collection is the device's own data, not a cache: it survives
clearAuth(), and underpersistence: falseit lives in memory for the life of the process instead of silently going nowhere.
Encryption #
AES-256-GCM encryption, two modes:
// Per-collection: only sensitive collections are encrypted
// Global: ALL collections are encrypted
final app = await Aquabase.init(
projectId: 'your_project_id',
apiKey: 'aq_pub_...',
encrypted: true,
);
When encrypted: true is set globally, every collection is encrypted automatically — no need to set encrypted on each one.
Key derivation: Local encrypted collections derive their AES key via HKDF from the per-user key delivered in the connection handshake, so the first use requires a session. Remote encrypted collections use the server-provided DEK.
End-to-End Encryption #
Per-collection E2E with transparent key management. The X25519 private identity is sealed client-side with a wrap key derived from the user's password via PBKDF2 (600K iter) + HKDF — the server stores only an opaque sealed blob and the public key. Multi-device works automatically: the second device logs in, derives the same wrap key, downloads and unseals the blob. Session keys are derived once per peer via ECDH + HKDF and cached in RAM. Payloads are sealed with AES-256-GCM.
The server and the admin console only observe metadata (senderUid, peerUid, timestamp, size). Contents are never readable server-side.
// Auth is installed before login returns.
final result = await app.auth.login('user@example.com', 'secret123');
await app.unlockE2E('secret123', result.kdfSalt!);
// OAuth: ask the user for an explicit passphrase (the OAuth token alone
// can't derive the wrap key). Use the same passphrase on every device.
final oauth = await app.auth.exchangeOAuthCode(code);
await app.unlockE2E(userPassphrase, oauth.kdfSalt!);
final chats = app.collection('chats', e2e: true);
await chats.doc(id).set({'text': 'hi'}, peerUid: 'uB');
final msg = await chats.doc(id).get(peerUid: 'uB');
E2E files #
Files (images, videos, attachments) never touch the database and never reach the server in plaintext. The client seals each blob with a fresh random 32-byte key, uploads the ciphertext to vault-storage, and embeds a small envelope inside the E2E message. The server — and the admin console — only ever see opaque bytes.
// 1. Seal + upload. Returns an envelope containing the file key + storage path.
final envelope = await app.e2e.uploadFile(
'chat_media',
imageBytes,
mime: 'image/jpeg',
name: 'photo.jpg',
);
// 2. Send the envelope inside an E2E message.
await chats.doc(id).set({'text': '', 'attachment': envelope.toMap()}, peerUid: 'uB');
// 3. On the receiving side — resolve, fetch, decrypt.
final msg = await chats.doc(id).get(peerUid: 'uA');
final envelope2 = E2EFileEnvelope.fromMap(msg!['attachment'] as Map<String, dynamic>);
final bytes = await app.e2e.downloadFile(envelope2);
// 4. Caller-driven cleanup once both peers have the blob.
await app.e2e.deleteFile(envelope);
Default per-blob cap: e2eMaxFileBytes (1 GiB; matches the server). Pass
maxBytes: on uploadFile to tighten it per call. Self-hosted deployments can
shrink or extend the server cap with AQUABASE_MAX_FILE_BYTES.
Notes #
- E2E collections support
doc(id).set/getandbulkSet/bulkGetwith onepeerUidper call.bulkDeleteneeds no peer context.where— and with itwatch(query),countandexists— is not available on E2E collections: results may span multiple peers, and the server cannot index ciphertext. collection.localKeys()returns the document IDs present in the local cache for that collection — useful when broad queries are disabled and the caller still needs to list offline-available docs by ID.recoverE2E(password, kdfSalt)creates a new identity when the previous one cannot be unlocked. Existing E2E history for that identity becomes permanently unreadable.
Date Handling #
DateTime objects are automatically serialized as milliseconds since epoch (UTC). On read, timestamps are returned as int — convert in your app:
// Write
await users.doc('u1').set({
'name': 'Ana',
'createdAt': DateTime.now(),
});
// Read
final user = await users.doc('u1').get();
final date = DateTime.fromMillisecondsSinceEpoch(user!['createdAt'], isUtc: true);
This format is cross-platform compatible with the Node/Web SDK.
Offline-First #
Works without connection. Writes go to local cache and queue for sync.
get() and exists() serve the cache first and wait out a first handshake
still in flight, so a read issued right after setAuth or during a cold start
is answered by the server. With nothing cached and the channel down they throw
WsOfflineException, so a null always means "no such document".
app.backlogErrors.listen((event) {
print('${event.reason}: ${event.ops} ops, ${event.bytes} bytes');
});
// Writes the server never accepted — unreachable, or denied by the rules.
app.writeErrors.listen((event) => print('${event.fullKey}: ${event.error}'));
await app.collection('users').doc('u1').set({'name': 'Ana'});
print(app.pendingOfflineOps);
// On reconnect, queue flushes automatically
// Or flush manually:
await app.flushOfflineQueue();
P2P LAN Sync #
Devices on the same local network sync instantly over LAN without waiting for cloud roundtrip. Disabled by default.
final app = await Aquabase.init(
projectId: 'your_project_id',
apiKey: 'aq_pub_...',
localSync: LocalSync.enabled,
);
// That's it. All collections automatically propagate
// writes to other devices on the same LAN.
How it works:
- Devices discover each other via UDP broadcast on port 45831
- Writes populate peer caches instantly, watchers fire as if the change came from the server
- HMAC-SHA256 validates every packet — only peers from the same project can communicate
- All data goes through the normal cloud sync path too — P2P is an acceleration layer, not a replacement
Custom config:
localSync: LocalSync(
discoveryPort: 45831, // UDP port
announceInterval: Duration(seconds: 10),
peerTtl: Duration(minutes: 60),
maxDatagramBytes: 1200, // ops larger than this skip local sync
freshnessWindow: Duration(seconds: 30),
)
Platform permissions #
Android — add to AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE"/>
Android filters broadcast/multicast packets by default to save battery. You must acquire a MulticastLock in your MainActivity for P2P discovery to work:
// MainActivity.kt
import android.content.Context
import android.net.wifi.WifiManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
private var multicastLock: WifiManager.MulticastLock? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val wifi = applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager
multicastLock = wifi.createMulticastLock("aquabase_p2p").apply {
setReferenceCounted(true)
acquire()
}
}
override fun onDestroy() {
multicastLock?.let { if (it.isHeld) it.release() }
super.onDestroy()
}
}
iOS — add to Info.plist:
<key>NSLocalNetworkUsageDescription</key>
<string>This app uses the local network to sync data with nearby devices.</string>
<key>NSBonjourServices</key>
<array>
<string>_aquabase._udp</string>
</array>
iOS 14+ will show a system prompt asking the user for local network access. If denied, local sync is silently disabled and data syncs through the cloud only.
Note: Local sync works on Android, iOS, and desktop. Web is not supported because browsers cannot open UDP sockets.
Custom Server URL #
final app = await Aquabase.init(
projectId: 'your_project_id',
apiKey: 'aq_pub_...',
url: 'http://localhost:3280',
);
Auth #
// Email/password
final result = await app.auth.register('user@example.com', 'password123');
final login = await app.auth.login('user@example.com', 'password123');
// Create additional accounts: no session is issued (server or client),
// the active session is untouched
final uid = await app.auth.createUser('other@example.com', 'password123');
// Google sign-in is native: one call shows the system sheet (Credential
// Manager on Android, the Google SDK on iOS/macOS) listing the accounts
// already on the device — no browser, no redirect, nothing leaves the app.
// The project's Google client id doubles as the token audience, so the only
// setup is the app's own OAuth client in the same Google Cloud project:
// Android needs its package name + signing SHA-1 (one per signing key, so
// debug and release each need their own), iOS its bundle id. Those clients
// carry no secret — one that hands you a secret is a Web client, not a
// mobile one, and the sheet will not open without the mobile one.
// A domain restriction on the provider reaches the sheet as well: accounts
// outside it are hidden, so a device holding none of them looks empty.
// null means the user dismissed the sheet; a GoogleNativeSignInException
// means the sheet could not run — the message says why (no provider
// configured, unsupported platform, Google error).
final session = await app.auth.signInWithOAuth(context, OAuthProvider.google);
// Other providers run hosted by Aquabase: the provider answers to Aquabase,
// never to the app — no deep link, no manifest entry, nothing registered
// with GitHub. A sheet holds the attempt while the provider runs in a
// Custom Tab (Android) or SFSafariViewController (iOS), the browser closes
// itself and the session is installed. null means the user backed out.
final github = await app.auth.signInWithOAuth(context, OAuthProvider.github);
// The hosted Google flow is still available for a browser you drive yourself:
// app.auth.startOAuthPairing(provider) → open the URL → awaitOAuthPairing(secret).
// With a deep link of your own, the browser can come back to the app instead:
// app.auth.oauthUrl(provider, 'myapp://auth') → exchangeOAuthCode(aq_code).
// The target must be listed as an allowed origin of the project.
// From a credential the app already holds (google_sign_in, GitHub code).
final google = await app.auth.signInWithIdToken(OAuthProvider.google, googleIdToken);
print(login.accessToken);
print(login.refreshToken);
// The provider's profile travels with the session and survives a refresh.
// Both are null for accounts the provider gave no profile for, such as
// email/password ones.
Text(app.authSession?.name ?? app.authSession!.email);
if (app.authSession?.picture case final url?) Image.network(url);
// Best-effort server logout, durable local deletion, then anonymous reconnect.
await app.clearAuth();
Roles and delegated user management #
Every account carries a role — user by default — that rules read as
role(admin). Assigning it requires authority the public key does not have, so
the client cannot promote itself. Grant that authority in the database rules:
auth {
create: role(admin)
update: role(admin)
delete: self
roles: [teacher, school]
}
create/update/delete decide who may manage accounts; roles bounds which
roles they may assign, and a gate role can never be listed there — an admin
cannot mint admins, or take over one by resetting its password. Without an
auth block only the project secret key manages users.
// The signed-in admin's access token proves the gate and is sent automatically.
final uid = await app.auth.createUser(
'teacher@example.com', 'password123', role: 'teacher');
await app.auth.updateUser(uid, role: 'school', disabled: true);
await app.auth.deleteUser(uid);
The optional jwt: argument overrides that token, for a standalone
AquabaseAuth with no session installed. With delete: self or
update: self, users manage their own account — the role allowlist does not
restrict them there.
Successful app.auth.register, login, and OAuth calls install and
durably persist the complete session automatically; createUser and refresh
only return one. Startup restores the session before opening the WebSocket, so
cached data is available immediately while offline.
Expired access tokens rotate through the refresh token automatically. kdfSalt is
preserved when a refresh response omits it.
Session expiration is stored as absolute expiresAt and refreshExpiresAt
epoch-millisecond timestamps rather than countdown durations.
The auth record is a dedicated, local-only CompactStore file under application
support. It is not encrypted or synced; the app sandbox and platform file
permissions are the security boundary. Set persistence: false to keep the
session, the document cache and the offline queue off disk entirely. Cached
documents and queues are cleared before principal transitions — local: true
collections are not, since they belong to the device rather than to the
principal — and switching directly between two authenticated UIDs on one live
instance is rejected; call and await clearAuth() first.
Storage #
Reads are local-first: a downloaded file lands in a persistent on-disk store
under path_provider’s application support directory
(…/aquabase/{projectId}/storage/) and later reads are served from it, with
the server hit only on cold miss.
Writes are not. upload(), uploadFile() and uploadStream() send straight
to the server and need a live connection: there is no local write and no
offline queue behind them, so an upload attempted offline fails at the call
site instead of being retried later. The local copy is what the next
download() fills in, after the server has accepted the object. This is the
one place where storage does not follow the database's model.
Buckets materialize automatically when you upload to them — there is no
explicit createBucket. Whether a path is publicly readable is determined by
your storage rules; mark it accessible via publicUrl() by writing
allow read: true on its pattern. Bucket administration (deletion, listing,
metrics) lives in the Aquabase console.
A path is a /-separated key, not a filesystem path: . and .. segments are
rejected rather than resolved.
final bytes = await File('photo.jpg').readAsBytes();
final up = await app.storage.upload('avatars', 'user1.jpg', bytes, contentType: 'image/jpeg');
// up.url → final file URL (also: bucket, path, size, contentType, compressed)
// Large files: stream from disk (RAM stays bounded by chunk size).
await app.storage.uploadFile('avatars', 'user1.mp4', File('/path/to/video.mp4'),
contentType: 'video/mp4');
// Source of unknown length: the size is declared with the last range.
await app.storage.uploadStream('avatars', 'user1.mp4', byteStream,
contentType: 'video/mp4');
// Local-first: local store → server.
final data = await app.storage.download('avatars', 'user1.jpg');
// Same resolution, streamed: memory stays at one chunk however large the file.
// Server transfers are not replicated — `download` is what fills the replica.
await app.storage.downloadToFile('videos', 'clip.mp4', File(target));
await for (final chunk in app.storage.downloadStream('videos', 'clip.mp4')) {
sink.add(chunk);
}
// Anonymous-readable path: direct HTTP URL, no auth, safe for Image.network.
// Produces: {storageUrl}/public/{projectId}/avatars/user1.jpg
final url = app.storage.publicUrl('avatars', 'user1.jpg');
// Authenticated path: download bytes and render with Image.memory; cache
// yourself if you need a long-lived widget-level image source.
final privateBytes = await app.storage.download('private-avatars', 'user1.jpg');
// Revalidate explicitly when needed.
final stillFresh = await app.storage.refresh('avatars', 'user1.jpg');
await app.storage.deleteFile('avatars', 'user1.jpg');
Storage URLs are derived from url: the server mounts the storage plane under
/storage on the same host. Pass storageUrl to serve it from a CDN instead.
Downloads are replicated to disk; nothing is retained in memory once a call
returns, though download necessarily materializes the whole object as a
Uint8List for the caller — downloadStream and downloadToFile do not, and
refresh writes a replacement straight to disk as it arrives. The replica belongs to the signed-in session: it is
addressed by a random scope minted at login plus the project credential, so no
other identity can read it, and it stops being served once that session expires.
A token refresh keeps it; signing out, switching user or signing in again
deletes it. upload and delete ops drop the affected entry. An object larger
than the budget is never replicated, and whatever a session left on disk when it
ended abruptly — a crash, a killed app — is deleted on the next sign-in.
final app = await Aquabase.init(
projectId: '...',
apiKey: '...',
// objectStoreBytes: 256 * 1024 * 1024 // 0 disables the local replica
);
Logs #
await app.logs.connect();
app.logs.info('db', 'User created account');
app.logs.warn('auth', 'Invalid password attempt', source: '192.168.1.1');
app.logs.error('ws', 'Connection timeout');
final records = await app.logs.query(LogQueryOptions(
since: DateTime.now().subtract(Duration(hours: 1)).microsecondsSinceEpoch * 1000,
minLevel: LogLevel.warn,
channel: 'auth',
limit: 100,
));
Crash Capture #
Enabled by default when connect() is called. Unhandled exceptions and Flutter errors are sent to the server automatically.
await app.logs.connect(); // Crashes captured automatically
Connection State #
// Reactive — fires on every connect/disconnect
app.connectionState.listen((connected) {
print(connected ? 'online' : 'offline');
});
// Synchronous check
if (app.isConnected) { /* ... */ }
Errors #
Con la caché activa (por defecto) set y delete son local-first: retornan al
aplicar el cambio localmente, así que un rechazo del servidor no llega al
await — llega por app.writeErrors. bulkSet y bulkDelete esperan el
reconocimiento del servidor y lanzan cuando este rechaza el batch; si el
backlog offline no puede tomarlo, lanzan BacklogOverflowException con
dropped = cuántas escrituras quedaron fuera. Las lecturas, auth y storage
también lanzan en el sitio de la llamada.
Por app.writeErrors — escrituras y borrados:
| Error | Cuándo | Qué hacer |
|---|---|---|
RefGuardException |
El borrado tiene dependientesref_restrict |
e.dependents dice qué documentos lo bloquean |
UniqueException |
Un grupo unique ya retiene ese valor | e.holder es la clave que lo tiene, e.fields el grupo |
WsDeniedException |
Las rules prohíben la escritura | Mostrar "sin permiso"; no se guardó nada |
app.writeErrors.listen((e) {
if (e.error is RefGuardException) {
final deps = (e.error as RefGuardException).dependents;
mostrar('${e.fullKey} no se pudo eliminar: ${deps.length} referencias');
}
});
Por try/catch — lecturas, auth y storage:
| Error | Cuándo | Qué hacer |
|---|---|---|
WsDeniedException |
Las rules rechazaron la lectura | Mostrar "sin permiso" |
IndexRequiredException |
La query necesita un índice que no existe | Ejecutare.command en deploy |
IndexFailedException |
El backfill del índice falló | Revisar el índice en la consola |
AuthException |
Login o registro fallido | e.statusCode == 401 → credencial inválida |
StorageException |
Subida o descarga fallida | Reintentar o avisar |
WsException |
Base de las de transporte | on WsException como red final |
try {
await app.auth.login(email, password);
} on AuthException catch (e) {
mostrar(e.statusCode == 401 ? 'Credenciales inválidas' : 'Servicio no disponible');
}
API Reference #
Aquabase #
| Method | Description |
|---|---|
Aquabase.init(...) |
Initialize with named parameters |
Aquabase.instance |
Global access after init |
collection(name) |
Get a collection (synced) |
collection(name, local: true) |
Get a local-only collection |
collection(name, encrypted: true) |
Get an encrypted collection (AES-256-GCM) |
collection(name, e2e: true) |
Get an E2E collection (X25519 + AES-256-GCM) |
isConnected |
WebSocket connection status (sync) |
connectionState |
Stream of connection state changes |
deniedOperations |
Stream of keys denied during flush |
flushResults |
Stream of automatic and manual flush results |
backlogErrors |
Stream of offline capacity or persistence errors |
writeErrors |
Stream of writes the server never accepted (unreachable or denied) |
setAuth(session) |
Install a completeAuthSession |
authSession |
Current restored or installed session |
clearAuth() |
Logout, durably clear, reconnect anonymously |
pendingOfflineOps |
Pending offline operations count |
flushOfflineQueue() |
Manually sync pending ops |
dispose() |
Awaitable resource release |
Init Options #
| Parameter | Type | Default | Description |
|---|---|---|---|
projectId |
String |
— | Project ID (required) |
apiKey |
String? |
— | API key; omit for pure local mode (no server) |
url |
String? |
Aquabase cloud | Server URL |
encrypted |
bool |
false |
Encrypt all collections with AES-256-GCM |
persistence |
bool |
true |
Write session, cache and offline queue to disk |
localOnly |
bool |
false |
Every collection is local; no server connection is opened |
directoryProvider |
Future<Directory> Function()? |
Platform default | Override the application storage directory |
localSync |
LocalSync? |
null |
PassLocalSync.enabled for LAN acceleration |
maxOfflineOps |
int |
10000 |
Maximum queued offline operations |
maxOfflineBytes |
int |
67108864 |
Maximum offline backlog size in bytes |
objectStoreBytes |
int |
268435456 |
Local storage replica size;0 disables it |
Collection #
| Method | Description |
|---|---|
doc(id) |
Document reference →.id .set() .get() .exists() .update(patch) .delete() |
add(data, {peerUid?, idPrefix?}) |
Auto-ID write, returns the ID.idPrefix scopes IDs |
bulkSet(docs, {peerUid?, chunkSize?}) |
Batch write, local-first likeset. E2E: peerUid required |
bulkGet(ids, {peerUid?, chunkSize?}) |
Batch read → Map<String, DocData>, not a list; a missing id is absent. E2E:peerUid required |
bulkDelete(ids, {chunkSize}) |
Batch delete; awaits the server and throws on denial |
count() / exists() |
Cardinalidad o presencia de claves sin devolver documentos |
where(f, ...) |
Query Builder →.orderBy() .get() .watch() (not available on E2E) |
orderBy(f, descending:) |
Query Builder ordenado porf, sin filtro propio |
watch('id') |
Real-time doc stream; unavailable on E2E collections |
localKeys() |
Local-cache-only document IDs (no server, no peer filter) |
dispose() |
Release resources |
QueryBuilder #
| Method | Description |
|---|---|
.where(f, eq:, from:, to:, ...) |
Add a filter; one range filter per query |
.orderBy(f, descending:) |
Ordena porf en el servidor; requiere índice range |
.get(limit:) |
Fetch all matching results |
.count() / .exists() |
Resultado escalar sin devolver documentos |
.fetchPage(limit, [afterId]) |
Fetch one page. Returns a QueryPage: docs plus nextCursor |
.paginate(pageSize) |
Stream — yields every QueryPage in sequence |
.watch(limit:) |
Real-time subscription stream |
Document #
| Method | Description |
|---|---|
set(data, {peerUid?}) |
Write a document |
get() |
Read from cache, fallback server (cache-only if local) |
exists({skipCache?}) |
Check key presence without downloading the payload |
delete({localOnly?}) |
Delete a document |
Auth #
| Method | Description |
|---|---|
app.auth.register(email, password) |
Register and install anAuthSession |
app.auth.createUser(email, password, {role}) |
Create an account, returns its uid — no session issued or installed |
app.auth.updateUser(uid, {disabled, role, email, password}) |
Change any of those fields |
app.auth.deleteUser(uid) |
Delete the account, its email index and sealed E2E identity |
app.auth.login(email, password) |
Login and install anAuthSession |
app.auth.signInWithOAuth(context, provider) |
Full OAuth sign-in: native system sheet for Google, hosted browser flow for other providers; null if dismissed |
app.auth.signInWithGoogleNative() |
Native Google sheet without a BuildContext; throws GoogleNativeSignInException when it cannot run |
app.auth.startOAuthPairing(provider) |
URL to open + secret to await, for a browser you open yourself |
app.auth.awaitOAuthPairing(secret) |
Wait for the browser side, installs the session |
app.auth.oauthUrl(provider, redirectTo) |
Hosted flow returning to your own deep link |
app.auth.exchangeOAuthCode(code) |
Redeem the aq_code from the return link, installs the session |
app.auth.signInWithIdToken(provider, credential) |
OAuth login from an existing credential, auto-registers |
app.auth.refresh(refreshToken) |
Rotate a session with its refresh token |
app.authSession |
Active session: uid, email, role, name, picture, tokens and expirations |
app.unlockE2E(password, kdfSalt) |
Activate E2E (call after login if using E2E) |
app.recoverE2E(password, kdfSalt) |
Replace an inaccessible E2E identity |
Storage #
| Method | Description |
|---|---|
app.storage.upload(bucket, path, bytes, type?) |
Upload (Uint8List; auto-chunks > 5 MB). Needs a connection — no offline queue. Returns UploadResult |
app.storage.uploadFile(bucket, path, file, type?) |
Streaming upload fromdart:io File. Returns UploadResult |
app.storage.uploadStream(bucket, path, source, type?) |
Streaming upload from a Stream<List<int>> of unknown length |
app.storage.download(bucket, path) |
Local-first download as bytes |
app.storage.downloadStream(bucket, path) |
Local-first download as a chunk stream, bounded memory |
app.storage.downloadToFile(bucket, path, file) |
Streams an object straight to a file, returns the bytes written |
app.storage.refresh(bucket, path) |
Revalidate against server |
app.storage.publicUrl(bucket, path) |
Direct HTTP URL for rule-allowed anonymous reads |
app.storage.deleteFile(bucket, path) |
Delete a file |
app.storage.deleteFiles(bucket, paths) |
Delete multiple files |
app.storage.clearLocalStore() |
Drop the session's local replica |
Logs #
| Method | Description |
|---|---|
app.logs.connect() |
Connect + enable crash capture |
app.logs.info(channel, msg) |
Push info-level log |
app.logs.warn(channel, msg) |
Push warn-level log |
app.logs.error(channel,msg) |
Push error-level log |
app.logs.flush() |
Flush buffer to server |
app.logs.query(opts?) |
Query stored logs |
app.logs.dispose() |
Disconnect + release resources |
Runtime Guarantees #
- Database reads and writes are local-first when persistence is enabled; file uploads are not.
- Offline database writes retry automatically after reconnection.
- Bulk operations split large inputs automatically.
- Synced collections support optional AES-256-GCM at-rest encryption and E2E collections use per-peer encryption.
- Optional LAN sync accelerates delivery without replacing cloud synchronization.
License #
Proprietary