aquabase 0.7.1 copy "aquabase: ^0.7.1" to clipboard
aquabase: ^0.7.1 copied to clipboard

PlatformAndroid

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 #

  1. All database reads/writes go to the local cache — instant, no network latency
  2. When online, changes sync to the server in background
  3. When offline, failed ops queue to disk automatically
  4. 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 NULL en 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 failed y 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 isIn por query. No se combina con from/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: 64 valores.

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 isIn ni con un eq sobre 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 range se 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 apiKey from Aquabase.init() to run as a local-only database — no server connection, all collections must use local: true. Or pass localOnly: true to make every collection(name) local automatically, even with an apiKey set; an explicit local: false then 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');

localOnly is independent of persistence: with the default persistence: true the data is durable; add persistence: false to keep it in RAM only.

A local collection is the device's own data, not a cache: it survives clearAuth(), and under persistence: false it 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/get and bulkSet/bulkGet with one peerUid per call. bulkDelete needs no peer context. where — and with it watch(query), count and exists — 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

0
likes
140
points
125
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter SDK for Aquabase — encrypted real-time database with offline sync, tags, and binary transport.

Homepage
Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

android_id, cryptography, cryptography_flutter, device_info_plus, flutter, google_sign_in, meta, msgpack_dart, package_info_plus, path_provider, url_launcher, web_socket_channel

More

Packages that depend on aquabase