npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@icedigital/aquabase

v0.7.6

Published

Ultra-fast encrypted real-time database client. Offline-first, zero codegen, cloud sync.

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/aquabase

Quick 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

  1. All reads/writes go to the local cache — instant, no network latency
  2. When online, changes sync to the server in background (auto-batched)
  3. When offline, failed ops queue to disk automatically
  4. 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 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.

const app = await Aquabase.init({ localOnly: true });
// Every collection is local; no connection to Aquabase Cloud is opened.
const notes = app.collection("notes");

localOnly is independent of persistence: with default storage the data is durable; add storage: false to 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 }), and bulkSet/bulkGet with one peerUid per batch. bulkDelete needs no peer context. where(...) — and with it watch(query), count and exists — 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 by peerUid. 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 collection

A 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 in per query. in cannot be combined with from/to/gt/lt.
  • Empty array returns an empty result without hitting the server.
  • A single value is folded into a normal eq filter.
  • Values are deduplicated. Cap is 64 values.

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 range index (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 an eq on 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-only auth_session.bin file before returning. Successful app.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. Set storage: false to 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 absolute expiresAt / refreshExpiresAt epoch 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 by localId, falling back to projectId. Node writes directly under AQUABASE_DATA_DIR (default ./.aquabase); an explicit localId creates 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 pass projectId in 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