@icedigital/aquabase
v0.7.5
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
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.
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();
// 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 (IDs sort chronologically within it)
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();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 }],
]),
);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.
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 from the
apiKeyvia HKDF — no server connection required. 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(id).get<{ attachment: E2EFileEnvelope }>({ 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(...)andwatch(query)are not available because query results may span multiple peers. 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 automatic. The first time you use .where('field', ...), the SDK registers that field as indexed and syncs it to the server. Existing server documents are backfilled automatically, and every following put() auto-generates tags for queried fields. No manual configuration needed.
Note: Backfill runs asynchronously on the server, but queries wait for it automatically —
where(...).get()resolves once the index is queryable (up to 60 s, then it throwsIndexBuildingError). To warm an index ahead of time, useapp.waitForIndex(collection, field);app.indexStatus(collection, field)returnstruewhile the backfill is still running.
Equality
const attendances = app.collection("attendances");
// .where() auto-registers 'date' and 'type' as indexed fields
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 server-side via the inverted-index bitmap — no documents are hydrated.
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.
Ordering
Use orderBy() to sort results client-side. Works with any numeric or string field.
// Scores highest first
const top = await scores
.where("classId", { eq: "math_101" })
.orderBy("score", { descending: true })
.get();
// Attendances sorted by date
const sorted = await attendances
.where("studentId", { eq: "stu_001" })
.orderBy("date")
.get();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 (_console.indexes), 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 _console.indexes:
{
"comments": { "postId": "ref_cascade" },
"invoices": { "customerId": "ref_restrict" },
"products": { "categoryId": "ref_setnull" }
}| Policy | Effect on delete(target) |
|---|---|
| ref_restrict | Aborts with RefGuardError 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.
import { RefGuardError } from "@icedigital/aquabase";
try {
await posts.doc("p1").delete();
} catch (e) {
if (e instanceof RefGuardError) {
for (const d of e.dependents) {
console.log(d.from, d.field, d.policy);
}
}
}
// Inspect dependents proactively
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();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);
// Local-first download (memory → 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: local-first blob URL backed by memory/OPFS.
// Do not persist it; request it again after remounts/cache invalidation.
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");Two tiers back every read: an in-memory blob cache (LRU, auto-sized from
device memory) and the persistent OPFS object store (256 MB, oldest files
evicted first). Both are caches over the server copy — eviction never loses
data; evicted files are re-fetched on next access. upload and delete ops
invalidate both tiers, so subsequent reads see fresh data.
const app = await Aquabase.init({
projectId: "...",
apiKey: "...",
// storageUrl: "https://storage.your-domain.dev", // optional; derived from url by default
blobCacheBytes: 100 * 1024 * 1024, // RAM blob cache (auto by default)
});
// Drop only the in-memory cache (keeps the persistent store):
app.storage.clearCache();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();API Reference
Constructor Options
| Option | Type | Default | Description |
| ------ | ---- | ------- | ----------- |
| projectId | string | — | Opaque project ID used by publicUrl() |
| apiKey | string | — | API key; omit for pure local mode (no server) |
| url | string | Aquabase cloud | API server URL |
| storageUrl | string | Derived from url | Storage host, e.g. https://storage.your-domain.dev |
| localId | string | projectId | Local storage namespace; never sent to the server |
| storage | false | Persistent | Set false for ephemeral server-only mode |
| 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 | — | Receives queue_full, storage_full, or unknown backlog failures |
| blobCacheBytes | number | Auto (50–500 MiB) | Maximum bytes in the in-memory blob cache |
Storage is a single source of truth.
Aquabase.init()resolves one persistent backend — OPFS in the browser (required; it throws if unavailable), a file underAQUABASE_DATA_DIR(default./.aquabase) in Node — and the DB cache and offline queue both live under one namespace (localId, falling back toprojectId). Always pass aprojectId(orlocalId): without one the namespace falls back todefault, so multiple projects on the same origin would share — and collide in — one 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 |
| waitForIndex(col, field, timeoutMs?) | Resolve when the index is queryable; throws IndexBuildingError on timeout |
| indexStatus(col, field) | true while an index backfill is running |
| pendingOps | Queued offline ops count |
| offlineBacklog() | Current queued { ops, bytes } |
| close() | Flush + close connection + release resources |
Collection
| Method | Description |
| ------------------------- | -------------------------------------------------------- |
| doc(id) | Document reference →.set(data, opts?) .get(opts?) .delete(opts?) |
| put(id, data, opts?) | Direct write (alias of doc(id).set) |
| get(id, opts?) | Direct read (alias of doc(id).get) |
| delete(id, opts?) | Direct delete. { localOnly: true } skips server sync |
| add(obj, opts?) | Auto-ID write, returns the ID. { idPrefix } scopes IDs |
| bulkSet(docs, {peerUid?, chunkSize?}) | Batch write. E2E: peerUid required |
| bulkGet(ids, {peerUid?, chunkSize?}) | Batch read. E2E: peerUid required |
| bulkDelete(ids) | Batch delete; chunks larger inputs automatically |
| where(f, ...) | Query Builder →.orderBy() .get() .watch() (not available on E2E) |
| 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 filter condition (chainable) |
| .orderBy(f, opts?) | Sort results client-side |
| .get(limit?) | Fetch all matching results |
| .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) | Create an account, returns its uid — no session issued or installed |
| 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.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 a file (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 as Uint8Array |
| 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) | Browser blob: URL backed by memory/OPFS |
| app.storage.deleteFile(bucket, path) | Delete a file |
| app.storage.deleteFiles(bucket, paths) | Delete multiple files |
| app.storage.clearCache() | Drop in-memory blob cache only |
| app.storage.clearLocalStore() | Drop the persistent local object store |
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
