@icedigital/aquabase
v0.7.6
Published
Ultra-fast encrypted real-time database client. Offline-first, zero codegen, cloud sync.
Maintainers
Readme
Aquabase
Ultra-fast offline-first database with automatic cloud sync, user auth, and file storage. Zero codegen, pure TypeScript.
Works in Node.js, Bun, Deno, and Browser.
Install
pnpm add @icedigital/aquabaseQuick Start
Initialize Aquabase in a single file to prevent redundant connections:
src/aquabase.ts
import { Aquabase } from "@icedigital/aquabase";
// 1. Initialize once — restores the local cache and starts the connection
// in background. Offline is a state, never an error.
const app = await Aquabase.init({
url: "YOUR_SERVER_URL", // optional (defaults to Aquabase cloud)
projectId: "YOUR_PROJECT_ID",
apiKey: "YOUR_API_KEY",
});
// 2. Extract sub-modules
const auth = app.auth;
const storage = app.storage;
const logs = app.logs;
const e2e = app.e2e;
// 3. Multi-export for clean imports across your app
export { app, auth, storage, logs, e2e };Usage anywhere in your app:
import { app, auth, storage } from './aquabase';
// Database
const users = app.collection("users");
await users.doc("u1").set({ name: "Ana", age: 25 });
const user = await users.doc("u1").get();
// Auth
const { uid, accessToken, refreshToken, kdfSalt } = await auth.register("[email protected]", "secret123");
// Storage
// Buckets auto-create on first upload — no provisioning needed.
await storage.upload("avatars", "photo.jpg", file);How It Works
- All reads/writes go to the local cache — instant, no network latency
- When online, changes sync to the server in background (auto-batched)
- When offline, failed ops queue to disk automatically
- On reconnect, queued operations flush to the server automatically
The offline backlog accepts up to 10,000 operations or 64 MiB by default. A write that would exceed either limit is rejected without partially queuing a bulk operation. Use onBacklogError to surface capacity or persistence failures.
Collections
A collection name is 1–128 characters of A-Za-z0-9._- — the same rule the
server enforces, applied to local collections too.
Each serialized database value is limited to 1 MiB (MAX_VALUE_BYTES). The
SDK rejects larger values before caching or syncing them; use app.storage for
files or larger binary content.
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 behaves the same way,
whatever the batch size. Pass onWriteError to init() to react to a write
that did not go through; without it the SDK logs the failure 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 onWriteError fires
immediately with a WsDeniedError, 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 onWriteError reports it.
const app = await Aquabase.init({
projectId: "my-project",
apiKey: "...",
onWriteError: ({ key, error }) => toast(`No se guardó ${key}: ${error}`),
});
const users = app.collection("users");
// Document CRUD
await users.doc("u1").set({ name: "Ana", age: 25 });
const 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
const id = await users.add({ name: "Carlos", age: 30 });
console.log(id); // e.g. 'aB3xK9mQ7pR2wT4vN1'
// Scope generated IDs under a prefix (groups related docs by key prefix)
const msgId = await messages.add(msg, { idPrefix: `${chatId}_` });
// Bulk operations
await users.bulkSet(
new Map([
["u1", { name: "Ana" }],
["u2", { name: "Luis" }],
]),
);
const results = await users.bulkGet(["u1", "u2"]);
await users.bulkDelete(["u1", "u2"]);Real-Time
Watch a document or a query for changes — fires immediately with current data, then on every update from any device.
const users = app.collection("users");
// Watch a single document
const unsub = users.watch("u1", (user) => {
if (user) renderUser(user);
});
// Watch a query
const unsub2 = users.where("status", { eq: "active" }).watch((results) => {
for (const [id, user] of results) {
console.log(id, user);
}
});
// Stop watching
unsub();
unsub2();UI lifecycle (optional): If users frequently navigate away from and back to a live view, keep its subscription and last rendered state above the route, or preserve the view with the framework's keep-alive mechanism (for example, Vue
KeepAlive). The active watcher continues receiving cloud updates while hidden, avoiding a fresh loading state on every remount. Unsubscribe when the 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.
const user = await users.doc("u1").get();Local Collections
Same API as any collection, but data stays on the device — no server sync.
const students = app.collection("_students", { local: true });
// Same API as any collection
await students.put("s1", { name: "Ana", age: 12, grade: "6A" });
const student = await students.get("s1");
await students.delete("s1");
await students.bulkSet(
new Map([
["s1", { name: "Ana", age: 12 }],
["s2", { name: "Luis", age: 13 }],
]),
);
// Live too: a local write re-emits only to the watchers it actually affects.
const stop = students.where("grade", { eq: "6A" }).watch(snap => render(snap));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.
const app = await Aquabase.init({ localOnly: true });
// Every collection is local; no connection to Aquabase Cloud is opened.
const notes = app.collection("notes");
localOnlyis independent of persistence: with default storage the data is durable; addstorage: falseto keep it in RAM only.
A local collection is the device's own data, not a cache: signing out clears the cached server documents and the offline queue, and leaves it alone. No server holds a second copy of it.
Encryption
AES-256-GCM encryption, two modes:
// Per-collection: only sensitive collections are encrypted
const secrets = app.collection("secrets", { local: true, encrypted: true });
await secrets.put("profile", { email: "[email protected]" });
// Global: ALL collections are encrypted
const 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.
// Login installs and persists the complete session automatically.
const result = await app.auth.login("[email protected]", "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.
const oauth = await app.auth.signInWithOAuth("google", googleIdToken);
await app.unlockE2E(userPassphrase, oauth.kdfSalt!);
const chats = app.collection("chats", { e2e: true });
await chats.doc(id).set({ text: "hi" }, { peerUid: "uB" });
const msg = await chats.doc(id).get({ peerUid: "uB" });
// Batch ops within a single conversation: pass `peerUid` once and the
// session key is resolved a single time for the whole batch.
const history = await chats.bulkGet(messageIds, { peerUid: "uB" });
await chats.bulkSet(seedMessages, { peerUid: "uB" });
await chats.bulkDelete(["m1", "m2"]);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.
const 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 }, { peerUid: "uB" });
// 3. On the receiving side — resolve, fetch, decrypt.
const msg = await chats.doc<{ attachment: E2EFileEnvelope }>(id).get({ peerUid: "uA" });
const bytes = await app.e2e.downloadFile(msg!.attachment);
// 4. Caller-driven cleanup once both peers have the blob.
await app.e2e.deleteFile(envelope);Default per-blob cap: E2E_MAX_FILE_BYTES (1 GiB; matches the server). Pass
{ maxBytes } to 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,collection.watch(id, cb, { peerUid }), andbulkSet/bulkGetwith onepeerUidper batch.bulkDeleteneeds no peer context.where(...)— and with itwatch(query),countandexists— is not available: results may span multiple peers, and the server cannot index ciphertext. recoverE2E(password, kdfSalt)creates a new identity when the previous one cannot be unlocked. Existing E2E history for that identity becomes permanently unreadable.collection.localKeys()returns the document IDs present in the local cache only for that collection — it does not query the server or filter bypeerUid. Useful when broad queries are disabled and the caller still needs to list offline-available docs by ID. Filter by conversation using an ID prefix convention (e.g.${pairKey}_${pushId}).- E2E requires Web Crypto X25519 (evergreen browsers or Node.js 20+).
Date Handling
DateTime/Date objects are automatically serialized as milliseconds since epoch (UTC). On read, timestamps are returned as number — convert in your app:
// Write
await users.put("u1", { name: "Ana", createdAt: new Date() });
// Read
const user = await users.get<{ name: string; createdAt: number }>("u1");
const date = new Date(user.createdAt);This format is cross-platform compatible with the Flutter SDK.
Indexes
Indexes are declared in database.indexes.toml and applied with the CLI, which is the only path that carries owner authority — the client SDK never registers one. Once a field is deployed, every put() auto-generates its tags.
aquabase indexes add attendances date type # declare in the manifest
aquabase indexes deploy # apply: one backfill per collectionA query over an undeclared field throws IndexRequiredError, whose command property is the exact aquabase indexes add line to run. While a backfill is in flight the query answers from cache (snapshot.state === 'syncing'); if it fails, the query throws IndexFailedError.
Provenance
Every query resolves against the device when the server is out of reach — the local index is derived on demand from the cache, so operators, ordering and pagination behave the same offline. What changes is what the answer covers, and each snapshot says so:
| source | partial | Meaning |
| ---------- | --------- | -------------------------------------------------------------- |
| 'server' | false | The server answered over the whole collection |
| 'cache' | true | Answered from the device: complete only for what it already has |
| 'local' | false | A local: true collection — the device is the authority |
const snapshot = await attendances.where("type", { eq: "present" }).get();
if (snapshot.partial) { /* what the device has, not the whole collection */ }Equality
const attendances = app.collection("attendances");
const results = await attendances
.where("date", { eq: "2026-03-18" })
.where("type", { eq: "present" })
.get();
// Real-time query
const unsub = attendances
.where("date", { eq: "2026-03-18" })
.watch((results) => {
for (const [id, data] of results) {
console.log(id, data);
}
});
unsub();Range Queries
Use from/to for inclusive ranges (most common). The SDK automatically selects the BTreeMap index.
const scores = app.collection("scores");
// Scores between 80 and 100
const high = await scores
.where("score", { from: 80, to: 100 })
.get();
// March attendances for a student
const march = await attendances
.where("date", { from: "2026-03-01", to: "2026-03-31" })
.where("studentId", { eq: "stu_001" })
.get();
// Exclusive comparisons
const above80 = await scores
.where("score", { gt: 80 })
.get();All available operators:
| Operator | Type | Description |
| -------- | ------------ | -------------------------- |
| eq | any | Exact equality |
| in | any[] | Match any of N values (OR) |
| from | num / string | Range start (≥ inclusive) |
| to | num / string | Range end (≤ inclusive) |
| gt | num / string | Greater than (> exclusive) |
| lt | num / string | Less than (< exclusive) |
Works with dates (ISO 8601), numbers, and strings. Numbers use IEEE 754 big-endian encoding, so -5 < 0 < 100 is always correct.
in (multi-value match)
in matches any value in the list. Combine with eq filters to narrow the base set.
const tickets = app.collection("tickets");
// All tickets in any of these statuses
const open = await tickets
.where("status", { in: ["active", "pending", "draft"] })
.get();
// Owner's tickets in any of these statuses (base eq + in)
const mine = await tickets
.where("owner", { eq: "u1" })
.where("status", { in: ["active", "pending"] })
.get();Rules:
- One
inper query.incannot be combined withfrom/to/gt/lt. - Empty array returns an empty result without hitting the server.
- A single value is folded into a normal
eqfilter. - Values are deduplicated. Cap is
64values.
count and exists
Both resolve on the server as a bitmap operation, without hydrating documents.
const open = await tickets
.where("owner", { eq: "u1" })
.where("status", { in: ["active", "pending"] })
.count();
const hasOpen = await tickets
.where("owner", { eq: "u1" })
.where("status", { in: ["active", "pending"] })
.exists();count returns a number. exists short-circuits at the first match. Both fall back to the device like any other query, but a bare number carries no provenance — check app.isConnected when the difference between "none in the collection" and "none on this device" matters.
Ordering
orderBy() walks the field's range index on the server, so a limit keeps the first rows of that order — orderBy(...).get(10) transfers ten documents, not the whole result set.
// Highest scores in a class
const top = await scores
.where("classId", { eq: "math_101" })
.orderBy("score", { descending: true })
.get(10);
// The ten most recent, with no filter at all
const latest = await posts.orderBy("createdAt", { descending: true }).get(10);Rules:
- The ordering field needs a
rangeindex (aquabase indexes add scores score:range). - It must be the field a range clause filters, if the query has one. It cannot be combined with
in, or with aneqon that same field. - Only values the index holds are ordered: present, non-empty, at most 1 KiB encoded. Numbers sort before strings.
Historical data: Existing documents written before a field was registered as
range-indexed are backfilled automatically; queries on that field wait for the backfill to finish before returning.
Pagination
Cursor-based pagination for efficient large dataset traversal. Each page costs O(page_size) — page 100 is as fast as page 1.
const users = app.collection("users");
// Fetch a single page
const { docs, nextCursor } = await users
.where("status", { eq: "active" })
.fetchPage(50);
// Fetch next page using nextCursor
const page2 = await users
.where("status", { eq: "active" })
.fetchPage(50, nextCursor);
// page2.nextCursor is undefined when no more pages
// Auto-iterate all pages
for await (const page of users.where("status", { eq: "active" }).paginate(50)) {
for (const [id, user] of page) {
console.log(id, user);
}
}Works with range queries too:
for await (const page of scores.where("score", { from: 80, to: 100 }).paginate(20)) {
// process page
}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:
const posts = app.collection("posts");
const comments = app.collection("comments");
const 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 withRefGuardError 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.
delete() is local-first, so the guard fires after the call resolves —
it arrives through onWriteError, not through a try/catch around the delete.
import { RefGuardError } from "@icedigital/aquabase";
const app = await Aquabase.init({
projectId: "my-project",
apiKey: "...",
onWriteError: ({ key, error }) => {
if (error instanceof RefGuardError) {
for (const d of error.dependents) console.log(key, d.from, d.field, d.policy);
}
},
});
// Inspect dependents proactively — this one does throw at the call site
const refs = await posts.doc("p1").incomingRefs();Custom Server URL
const app = await Aquabase.init({
projectId: "your_project_id",
apiKey: "aq_pub_...",
url: "http://localhost:3280",
});Auth
// Email/password
const { uid, accessToken, refreshToken } = await app.auth.register("[email protected]", "secret123");
const result = await app.auth.login("[email protected]", "secret123");
// Create additional accounts: no session is issued (server or client),
// the active session is untouched
const uid = await app.auth.createUser("[email protected]", "secret123");
// OAuth (Google, GitHub)
const google = await app.auth.signInWithOAuth("google", googleIdToken);
const github = await app.auth.signInWithOAuth("github", githubCode);
// Register/login/OAuth install automatically. A complete externally supplied
// session can also be installed on the same instance.
await app.setAuth(result);
// Revoke the refresh session, delete local auth/user state, and reconnect anonymously.
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.
const uid = await app.auth.createUser("[email protected]", "secret123", "teacher");
await app.auth.updateUser(uid, { role: "school", disabled: true });
await app.auth.deleteUser(uid);The trailing 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.
Session persistence:
Aquabase.init()restores the session from a dedicated local-onlyauth_session.binfile before returning. Successfulapp.auth.register(),login(), and OAuth sign-in persist automatically. The file is not encrypted; Node owner-only file permissions or OPFS origin isolation are the security boundary. Setstorage: falseto disable persistence.A rejected WebSocket access token is rotated with the refresh token and the complete session is persisted before retrying. Switching to a different UID on a live instance throws — call
clearAuth()first. Signing in after anonymous use clears the local cache and offline queue, so one user's queued writes can never sync as another user. Session expirations are stored as absoluteexpiresAt/refreshExpiresAtepoch timestamps, never as stale countdown durations.
Storage
Local-first object storage. Files are kept in a persistent OPFS-backed local store; the server is only consulted on first access. Same model as the database: if the file is local, no network round-trip.
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.
// Returns UploadResult: { bucket, path, size, content_type, compressed, url }
const { url } = await app.storage.upload("avatars", "u1.jpg", file);
// Any source works: Blob, File, Uint8Array, or a stream of unknown length.
// Anything over 5 MB is sent as resumable ranges; a stream is never buffered whole.
await app.storage.upload("videos", "clip.mp4", response.body, "video/mp4");
// Local-first download (local store → server).
const blob = await app.storage.download("avatars", "u1.jpg");
// Anonymous-readable path: direct HTTP URL, no auth, no blob lifecycle.
// Produces: {storageUrl}/public/{projectId}/avatars/u1.jpg
imgEl.src = app.storage.publicUrl("avatars", "u1.jpg");
// Authenticated path: blob URL over the local replica. You own it — revoke it
// with URL.revokeObjectURL, or its bytes stay in memory.
imgEl.src = await app.storage.getUrl("private-avatars", "u1.jpg");
// Explicit revalidation against the server (If-None-Match → 304 if fresh).
const stillFresh = await app.storage.refresh("avatars", "u1.jpg");
await app.storage.deleteFile("avatars", "u1.jpg");Downloads are replicated to a persistent OPFS store (256 MB, oldest files
evicted first); nothing is retained in memory once a call returns, though the
Blob it hands back lives as long as you hold it. 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,
so subsequent reads see fresh data. Eviction never loses data — evicted files
are re-fetched on next access. An object larger than the budget is never
replicated, and whatever a session left on disk when it ended abruptly — a
crash, a closed tab — is deleted on the next sign-in.
const app = await Aquabase.init({
projectId: "...",
apiKey: "...",
// storageUrl: "https://cdn.your-domain.dev", // optional; defaults to {url}/storage
});
// Drop the whole local replica:
await app.storage.clearLocalStore();getUrl() mints a blob: URL you own — call URL.revokeObjectURL when done;
nothing is retained by the SDK.
Logs
Structured log collection with auto-batching.
app.logs.connect();
app.logs.info("db", "User created account");
app.logs.warn("auth", "Invalid password attempt", "192.168.1.1");
app.logs.error("ws", "Connection timeout");
const records = await app.logs.query({
since: Date.now() * 1_000_000 - 3600e9,
minLevel: LogLevel.Warn,
channel: "auth",
limit: 100,
});
app.logs.close();Functions
Serverless functions, two kinds: WASM (raw .wasm binary, AOT-compiled on
the server) and proxy (HTTP reverse proxy with secret injection — call
third-party APIs without shipping keys to clients). Deploy, remove, and secret
management require an owner session (secret API key); invoking requires Read
access to functions/{name} via security rules.
// Deploy (owner only)
await app.functions.deployWasm("resize", wasmBytes);
await app.functions.secretSet("STRIPE_KEY", "sk_live_...");
await app.functions.deployProxy("stripe", {
url: "https://api.stripe.com",
inject: [{ target: "header", name: "Authorization", value: "Bearer $STRIPE_KEY" }],
});
// Invoke
const out = await app.functions.invoke("resize", inputBytes); // bytes in, bytes out
const resp = await app.functions.invokeProxy("stripe", {
method: "POST",
path: "/v1/charges",
body: payload,
}); // { status, headers, body }Webhooks
Inbound HTTP triggers (owner only): the server verifies the provider's
signature, then invokes a deployed function. Providers call
POST /webhooks/{projectId}/{name} directly.
await app.functions.secretSet("STRIPE_WH", "whsec_...");
await app.webhooks.deploy("stripe-events", {
target: "handle-stripe", // deployed function to invoke
secret: "STRIPE_WH", // secret name stored via secretSet
verify: { scheme: "stripe_v1" }, // or { scheme: "hmac_sha256", header: "X-Signature" }
});
const hooks = await app.webhooks.list();
await app.webhooks.remove("stripe-events");Connection State
// Reactive — fires on every connect/disconnect
const unsub = app.onConnection((connected) => {
console.log(connected ? 'online' : 'offline');
});
// Synchronous check
if (app.isConnected) { /* ... */ }
// Stop listening
unsub();Errors
set, bulkSet and delete are local-first: they resolve once the change is
in the local store, so a server rejection never reaches the await — it
reaches onWriteError. Reads, auth and storage do throw at the call site.
Through onWriteError — writes and deletes:
| Error | When | What to do |
| ----------------- | ----------------------------------------- | ----------------------------------------- |
| RefGuardError | The delete hasref_restrict dependents | error.dependents lists what blocks it |
| WsDeniedError | The rules forbid the write | Show "no permission"; nothing was stored |
const app = await Aquabase.init({
projectId: "my-project",
apiKey: "...",
onWriteError: ({ key, error }) => {
if (error instanceof RefGuardError) {
toast(`${key}: blocked by ${error.dependents.length} reference(s)`);
}
},
});Through try/catch — reads, auth and storage:
| Error | When | What to do |
| ----------------------- | -------------------------------------------- | ----------------------------------- |
| WsDeniedError | The rules rejected the read | Show "no permission" |
| IndexRequiredError | The query needs an index that does not exist | Runerror.command at deploy time |
| IndexFailedError | The index backfill failed | Check the index in the console |
| AuthRequiredError | No session, or the credential was rejected | Send the user back to sign-in |
| WsClientClosedError | The call ran afterclose() | Fix the lifecycle |
try {
await app.auth.login(email, password);
} catch (e) {
if (e instanceof AuthRequiredError) show('Invalid credentials');
}API Reference
Constructor Options
| Option | Type | Default | Description |
| ------------------- | ------------------ | ---------------------------------- | ------------------------------------------------------------------------- |
| projectId | string | — | Opaque project ID used bypublicUrl() |
| apiKey | string | — | API key; omit for pure local mode (no server) |
| url | string | Aquabase cloud | API server URL |
| storageUrl | string | {url}/storage | Storage base, when served from a separate host or CDN |
| localId | string | Browser:projectId; Node: unset | Explicit local storage partition; never sent to the server |
| storage | false | Persistent | Setfalse for ephemeral server-only mode |
| localOnly | boolean | false | Every collection is local; no server connection is opened |
| session | AuthSession | Restored | Explicit initial session; takes precedence over persisted auth |
| encrypted | boolean | false | Encrypt all collections with AES-256-GCM |
| l1Size | number | 1000 | Maximum entries in the memory cache |
| maxBatchSize | number | 256 | Maximum operations per network batch |
| maxOfflineOps | number | 10000 | Maximum queued offline operations |
| maxOfflineBytes | number | 67108864 | Maximum offline backlog size in bytes |
| onBacklogError | (info) => void | — | Receivesqueue_full, storage_full, or unknown backlog failures |
| onWriteError | (info) => void | Logs a warning | Receives writes the server never accepted (unreachable or denied) |
Storage is a single source of truth.
Aquabase.init()resolves one persistent backend. Browser OPFS is partitioned bylocalId, falling back toprojectId. Node writes directly underAQUABASE_DATA_DIR(default./.aquabase); an explicitlocalIdcreates a subdirectory when multiple clients intentionally share one data root. A project marker clears cache, queue and auth before a flat directory can be rebound to another project. Always passprojectIdin browsers so projects on the same origin cannot share a cache.
Aquabase
| Method | Description |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 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) |
| connect() | Await connectivity:true connected, false still offline (retrying). Throws only on permanent auth failure; init() auto-connects |
| setAuth(session) | Install and persist a complete auth session |
| clearAuth() | Revoke and clear auth/user state (awaitable) |
| session | Snapshot of the current restored session |
| isConnected | Server connection status (sync) |
| onConnection(callback) | Subscribe to connection state. Returns unsub |
| onSync(callback) | Subscribe to sync events. Returns unsub |
| pendingOps | Queued offline ops count |
| offlineBacklog() | Current queued{ ops, bytes } |
| close() | Flush + close connection + release resources |
Collection
| Method | Description |
| ----------------------------------------- | --------------------------------------------------------------------------------- |
| doc(id) | Document reference →.id .set(data, opts?) .get(opts?) .exists() .update(patch, opts?) .delete(opts?) |
| put(id, data, opts?) | Direct write (alias ofdoc(id).set) |
| get(id, opts?) | Direct read (alias ofdoc(id).get) |
| delete(id, {localOnly?}) | Delete a document |
| add(obj, opts?) | 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. E2E:peerUid required |
| bulkDelete(ids) | Batch delete; awaits the server and throws on denial |
| count() / exists() | Collection cardinality or key presence without returning documents |
| where(f, ...) | Query Builder →.orderBy() .get() .watch() (not available on E2E) |
| orderBy(f, opts?) | Query Builder ordered byf, with no filter of its own |
| watch(id, cb, opts?) | Real-time doc watch. E2E:opts.peerUid required. Returns unsubscribe |
| localKeys() | Local-cache-only document IDs (no server, no peer filter) |
QueryBuilder
| Method | Description |
| ------------------------------- | ------------------------------------------------- |
| .where(f, cond) | Add a filter; one range filter per query |
| .orderBy(f, opts?) | Order byf on the server; needs a range index |
| .get(limit?) | Fetch all matching results |
| .count() / .exists() | Scalar result without returning documents |
| .fetchPage(limit, afterId?) | Fetch one page. Returns{ docs, nextCursor? } |
| .paginate(pageSize) | Async generator — yields all pages automatically |
| .watch(cb, limit?) | Real-time subscription |
Auth
| Method | Description |
| -------------------------------------------- | -------------------------------------------------------------------- |
| app.auth.register(email, password) | Register and install a complete session |
| app.auth.createUser(email, password, role?, jwt?) | Create an account, returns its uid — no session issued or installed |
| app.auth.updateUser(uid, patch, jwt?) | Changedisabled, role, email or password |
| app.auth.deleteUser(uid, jwt?) | Delete the account, its email index and sealed E2E identity |
| app.auth.login(email, password) | Login and install a complete session |
| app.auth.signInWithOAuth(provider, cred) | OAuth login (Google/GitHub), auto-registers |
| app.auth.refresh(refreshToken) | Rotate a session with its refresh token |
| app.auth.logout(refreshToken) | Revoke a server refresh session |
| app.session | Active session: uid, email, role, 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, data, type?) | Upload aBlob/File/Uint8Array/stream (auto-chunks > 5 MB). Returns UploadResult with the final url |
| app.storage.download(bucket, path) | Local-first download as Blob |
| app.storage.downloadBytes(bucket, path) | Local-first download asUint8Array |
| app.storage.refresh(bucket, path) | Revalidate against server (If-None-Match) |
| app.storage.publicUrl(bucket, path) | Direct HTTP URL for rule-allowed anonymous reads |
| app.storage.getUrl(bucket, path) | Browserblob: URL the caller must revoke |
| 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 to server (auto-called on first push) |
| app.logs.info(channel, msg, src) | Push info-level log |
| app.logs.warn(channel, msg, src) | Push warn-level log |
| app.logs.error(channel, msg, src) | Push error-level log |
| app.logs.flush() | Flush buffer to server |
| app.logs.query(opts?) | Query stored logs |
| app.logs.close() | Disconnect + release resources |
Functions
| Method | Description |
| ------------------------------------------- | ------------------------------------------------------------- |
| app.functions.invoke(name, input) | Invoke a WASM function — bytes in, bytes out |
| app.functions.invokeProxy(name, req) | Invoke a proxy function. Returns{ status, headers, body } |
| app.functions.deployWasm(name, bytes) | Deploy a WASM function*(owner)* |
| app.functions.deployProxy(name, config) | Deploy an HTTP proxy function*(owner)* |
| app.functions.list() | List deployed functions |
| app.functions.remove(name) | Remove a function*(owner)* |
| app.functions.secretSet(name, value) | Store a secret —$NAME in proxy injections (owner) |
| app.functions.secretDelete(name) | Delete a secret*(owner)* |
| app.functions.secretList() | List secret names; values never exposed*(owner)* |
Webhooks
| Method | Description |
| ------------------------------------- | ------------------------------------ |
| app.webhooks.deploy(name, config) | Deploy an inbound trigger*(owner)* |
| app.webhooks.list() | List deployed webhooks |
| app.webhooks.remove(name) | Remove a webhook*(owner)* |
Runtime Guarantees
- Reads and writes are local-first when persistence is enabled.
- Offline 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.
License
Proprietary
