v12.0.0

Web SDK

Koolbase in the browser — auth, database, semantic search, storage, realtime, functions, feature flags, analytics, and an offline write queue with conflict resolution. TypeScript throughout, ESM and CommonJS, about 16 kB gzipped.

Same core as React Native. @koolbase/js and @koolbase/react-native are one codebase composed for two hosts, and one test suite runs against both. Where they differ, this page says so.

Installation

npm install @koolbase/js

No peer dependencies and no build configuration. It works in any modern browser and in any framework that renders in one — React, Vue, Svelte, Angular, or no framework at all.

Setup

Initialize once, as early as your app starts. Your publishable key is under Settings → Environments in the dashboard.

koolbase.ts
TypeScript
import { Koolbase, RestoreResult } from '@koolbase/js';

export async function startKoolbase() {
  await Koolbase.initialize({
    publicKey: 'pk_live_your_key_here',
    baseUrl: 'https://api.koolbase.com',
    analyticsEnabled: true,
    appVersion: '1.0.0',
    authTimeout: 10000, // optional, default 10s
  });

  // Restore a session persisted from a previous visit. Optimistic state is
  // read from storage before any network call, so authenticated UI renders
  // without a round trip.
  const result = await Koolbase.auth.restoreSession();

  switch (result) {
    case RestoreResult.Restored: return 'signed-in';
    case RestoreResult.Offline:  return 'signed-in'; // optimistic, no network yet
    case RestoreResult.Expired:  return 'signed-out';
    case RestoreResult.NoSession: return 'signed-out';
  }
}

Add your origin to Trusted Origins

The API refuses cross-origin requests from an origin it does not know. Add your app's origin — including http://localhost:3000 while developing — under Settings → Trusted Origins on your project. Without it every call fails as a CORS error in the browser console.

Auth

Email and password, phone with OTP, and OAuth. Once signed in, every database, storage and function call authenticates as that user — nothing to pass.

import { Koolbase } from '@koolbase/js';

// Two outcomes, and you must tell them apart: with verification required
// on the project, the account is created and NO session is issued.
const result = await Koolbase.auth.register({
  email: '[email protected]',
  password: 'password',
});
if (result.status === 'verification_required') {
  showCheckYourEmail(result.user.email);   // account exists, not signed in
} else {
  showApp();                               // result.session is live
}

const session = await Koolbase.auth.login({
  email: '[email protected]',
  password: 'password',
});

const me = Koolbase.auth.currentUser;

await Koolbase.auth.logout();
await Koolbase.auth.forgotPassword('[email protected]');

// Fires immediately with the current state, then on every change.
const unsubscribe = Koolbase.auth.onAuthStateChange((user) => {
  render(user ? 'signed in' : 'signed out');
});

Email verification and password reset

Both flows send the user a link carrying a token. Where that link points is yours to decide — set the URL templates under Branding in the dashboard, and your own page reads the token from the query string and passes it here. Left unset, the links fall back to an API-hosted page that completes verification without any configuration.

// Your /verify-email page, with ?token= from the link
await Koolbase.auth.verifyEmail(token);

// Your /reset-password page
await Koolbase.auth.resetPassword(token, newPassword);

// A "resend" button, for a signed-in but unverified user
const result = await Koolbase.auth.resendVerificationEmail();
if (result.alreadyVerified) {
  // They verified in another tab. Not an error — say so and move on.
  showVerified();
}

If your project requires a verified contact, a new account gets no session until it verifies — so a user whose email never arrived cannot sign in to ask for another. That is what the by-address call is for:

// A "didn't get it?" link on your check-your-email page
await Koolbase.auth.resendVerificationEmailToAddress(email);
// Show the same message either way — see below

It returns nothing and needs no session. The server answers identically whether the address has an account, has none, or is already verified — anything else would let anyone discover who has signed up — so show one message regardless, and never say “we sent it”.

resendVerificationEmail needs a session: the server verifies the caller rather than taking an email address, which is what stops it being an open mail relay. It is throttled — a short cooldown between sends and a daily cap — and a refusal arrives typed, carrying when to retry:

import {
  VerificationResendCooldownError,
  VerificationResendDailyCapError,
} from '@koolbase/js';

try {
  await Koolbase.auth.resendVerificationEmail();
} catch (e) {
  if (e instanceof VerificationResendCooldownError) {
    // e.cooldownUntil is a Date — show a countdown, not a bare refusal
    startCountdown(e.cooldownUntil);
  } else if (e instanceof VerificationResendDailyCapError) {
    showMessage('Try again tomorrow.');
  } else {
    throw e;
  }
}

Phone and OTP

await Koolbase.auth.sendOtp({ phoneNumber: '+233200000000' });
const result = await Koolbase.auth.verifyOtp({
  phoneNumber: '+233200000000',
  code: '123456',
});
if (result.isNewUser) showOnboarding();

Google and Apple

The native sign-in libraries are a React Native feature; a browser runs the provider's web OAuth flow instead. Get an ID token however you like, then hand it to the same method — the server-side verification is identical.

// After your web OAuth flow returns an ID token:
const session = await Koolbase.auth.signInWithGoogle({ idToken });

// Apple, likewise:
const session = await Koolbase.auth.signInWithApple({ identityToken });

Where the session lives

In IndexedDB by default, so a refresh does not sign the user out. The SDK picks its store by trying it: IndexedDB first, then localStorage, then memory. A browser in private mode, or one with site data blocked, degrades rather than failing.

import { browserPlatform } from '@koolbase/js';

// 'indexeddb' | 'localstorage' | 'memory'
const tier = await browserPlatform().storageTier();
if (tier === 'memory') {
  showNotice('Your session will end when you close this tab.');
}

Browser storage is not a keychain

IndexedDB, localStorage and every other store a page can read are readable by any script running on that page — an XSS vulnerability in your app exposes the session. That is true of every browser SDK from every vendor; the defences are a content-security policy and short session lifetimes, not a storage backend. If you need a different trade-off, implement KoolbaseAuthStorage and pass it as config.authStorage and choose where the token lives among the stores JavaScript can reach, or hand the SDK a token your own backend fetched. What this cannot be is an httpOnly cookie: if JavaScript cannot read it, the SDK cannot attach it to a request.

Database

await Koolbase.db.insert('posts', { title: 'Hello', published: true });

const { records, total, isFromCache } = await Koolbase.db.query('posts', {
  filters: { published: true },
  limit: 10,
  orderBy: 'created_at',
  orderDesc: true,
});

const post = records[0];
post.data.title;        // your fields live under .data
post.id, post.collection;

await Koolbase.db.update(post.id, { title: 'Updated' });
await Koolbase.db.delete(post.id);

total is the size of the set you are authorized to read, on every page — so paginating with limit and offset is exact rather than approximate.

Upsert, bulk delete, batches

import { Koolbase, BatchOp } from '@koolbase/js';

const { record, created } = await Koolbase.db.upsert(
  'profiles', { user_id: userId }, { weightKg: 70 },
);

const deleted = await Koolbase.db.deleteWhere('sessions', {
  user_id: userId, status: 'expired',
});

// All of these commit together, or none do.
const results = await Koolbase.db.batch([
  BatchOp.insert('orders', { total: 50, customer_id: customerId }),
  BatchOp.update(inventoryId, { stock: 9 }),
  BatchOp.delete(cartItemId),
]);

Online only

Upsert, deleteWhere and batch need the server's view to decide what to do, so unlike insert, update and delete they are never queued offline — they throw on network failure.

Offline

Reads come from a local cache when the network is gone, and insert, update and delete queue and replay when it returns. This is the same engine that runs in the React Native SDK, device-verified, not a browser approximation.

const { records, isFromCache } = await Koolbase.db.query('posts', { limit: 20 });

// Queued if offline; sent on reconnect, or force it:
await Koolbase.db.update(id, { title: 'Corrected' });
await Koolbase.db.syncPendingWrites();

// For a sync indicator, and to warn before signing out with unsent work:
const pending = await Koolbase.db.pendingWrites();
if (pending.length) showBadge(pending.length);

Conflicts

An offline edit is queued with the revision it was based on. If the record changed meanwhile — another device, another user, a Function — the server refuses the write on replay and it becomes a conflict you resolve, rather than an overwrite nobody sees.

const conflicts = await Koolbase.db.conflicts();

for (const c of conflicts) {
  c.local;            // the change the user made
  c.server;           // the record as the server holds it now
  c.divergentFields;  // where they disagree
  c.reason;           // concurrent_modification | baseline_unavailable

  await c.resolveWithLocal();
  // or c.resolveWithServer(), c.resolveWithMerge({ ... }), c.abandon()
}

Conflicts do not expire

They survive reloads and accumulate silently if nothing reads them — with the user's changes never applied. If you support offline editing, surface conflicts() somewhere. Automatic expiry would hide the problem while quietly losing the work.

Several tabs

Tabs on one origin share a queue and are coordinated through the Web Locks API: one tab replays it, state changes from different tabs cannot overwrite each other, and a tab that closes mid-sync hands off automatically. Nothing to configure.

Search

Semantic, lexical, or both fused together. Declare a vector field on the collection first, then let the server embed your records as they are written.

// Semantic — meaning, not keywords
const result = await Koolbase.db.searchSemantic({
  collection: 'articles',
  field: 'content_embedding',
  queryText: 'how do I move quicker?',
  limit: 10,
});

// Lexical — exact terms, codes, names
await Koolbase.db.searchSemantic({ ..., mode: 'lexical' });

// Hybrid — both, reciprocal rank fusion. Usually the best default.
await Koolbase.db.searchSemantic({ ..., mode: 'hybrid', minSimilarity: 70 });

for (const hit of result.hits) {
  hit.record.data.title;
  hit.distance;
}

Storage

Pass a File from an input, or any Blob you built. The upload presigns, sends the bytes, and confirms — the SDK handles all three.

const file = input.files[0];

const { object, downloadUrl } = await Koolbase.storage.upload({
  bucket: 'avatars',
  path: `user-${userId}.jpg`,
  file,                   // a File IS a Blob — pass it straight through
});

const url = await Koolbase.storage.getDownloadUrl('avatars', `user-${userId}.jpg`);
await Koolbase.storage.delete('avatars', `user-${userId}.jpg`);

Uploads are safe by default: a path that already exists throws KoolbaseStorageConflictError unless you pass overwrite: true. Bucket size caps, per-file caps and content-type allowlists arrive as typed errors, and MIME rejection happens at presign time so no bytes move.

Realtime

const unsubscribe = Koolbase.realtime.subscribe('messages', (event) => {
  if (event.type === 'deleted') {
    remove(event.recordId);
  } else {
    upsert(event.record!);
  }
});

// Later
unsubscribe();

One WebSocket, shared across subscriptions, reconnecting with backoff that doubles to a minute and resets on connect. Events are filtered by the collection's read rule, so a subscriber sees only what a query would return them.

Functions

const result = await Koolbase.functions.invoke('send-welcome-email', {
  userId: '123',
});

if (result.success) console.log(result.data);

The signed-in user's token is forwarded automatically, so the function reads the caller on ctx.auth. Failures are typed: FunctionNotFoundError, FunctionPermissionError, FunctionValidationError, FunctionQuotaExceededError, FunctionExecutionError.

Flags and remote config

if (Koolbase.isEnabled('new_checkout')) showNewFlow();

const timeout = Koolbase.configNumber('timeout_seconds', 30);
const apiUrl  = Koolbase.configString('api_url', 'https://api.myapp.com');
const dark    = Koolbase.configBool('force_dark_mode', false);

const v = Koolbase.checkVersion('1.2.3');
if (v.status === 'force_update') blockAndPrompt();

Rollout buckets are computed from a stable per-install device id, so a 10% rollout is genuinely 10% of visitors rather than a coin flip per page load.

Analytics

Koolbase.analytics.track('purchase', { value: 1200, currency: 'GHS' });
Koolbase.analytics.screenView('checkout');
Koolbase.analytics.identify(user.id);
Koolbase.analytics.setUserProperty('plan', 'pro');

Koolbase.analytics.reset();   // on sign-out

Events batch and flush every 30 seconds, when the tab is hidden, and on pagehide — so a visitor closing the tab does not lose the last few.

Server rendering

The SDK imports and initializes under Node without crashing, so a Next.js or Nuxt server render will not fail on it. With no browser storage available it keeps nothing, and restoreSession() returns NoSession.

It is a client SDK

One session per process, which is right for a browser tab and wrong for a server handling many users. Do not sign users in through it on a server: authenticate your server's own requests against the API instead. Rendering without crashing is not server-side authentication.

Error handling

Errors are selected from the server's stable error code, never from message text, and everything the SDK raises extends KoolbaseError.

import {
  KoolbaseError,
  KoolbaseDataError,
  KoolbaseConflictError,
  KoolbaseUnauthenticatedError,
  KoolbaseOfflineBaselineUnavailableError,
} from '@koolbase/js';

try {
  await Koolbase.db.upsert('users', { email }, { name });
} catch (e) {
  if (e instanceof KoolbaseUnauthenticatedError) {
    goToLogin();                  // already signed out by the time you catch it
  } else if (e instanceof KoolbaseConflictError) {
    showError(`That ${e.field ?? 'value'} is already taken.`);
  } else if (e instanceof KoolbaseDataError) {
    showError(e.message);
  } else {
    throw e;
  }
}

A 401 signs you out; a 403 does not

KoolbaseUnauthenticatedError means the credentials were refused, and the session is already cleared — route to login rather than retrying. KoolbasePermissionError means the credentials were accepted and this caller may not do that: nobody is signed out.

Not in the browser package

Stated so nothing is discovered as a method that fails:

  • Code push — it patches the Dart VM, which no browser has. The web already ships on deploy. Flutter only.
  • Push notifications — device tokens come from a native module. Use Web Push through your own service worker and backend.
  • The native sign-in libraries — the methods are here and work; only the credential-fetching library is native. Run the provider's web OAuth flow and pass the ID token.

Source and releases

@koolbase/js on npm · Changelog