Offline (IndexedDB)
createOfflineStore wraps Dexie with optional per-owner scoping. Use it for SSE/push history, drafts, and local cache that must survive a reload.
Why IndexedDB and not localStorage?
localStorage is synchronous, capped at ~5 MB, and only stores strings. IndexedDB is asynchronous, holds MBs of structured data, and supports indexes/queries. createOfflineStore hides Dexie's verbosity behind a typed CRUD — and dexie ships as a direct dependency of the SDK (v0.2.0+), installed alongside npm install tempest-react-sdk.
When to use
- Messages received via SSE/WebSocket that must remain visible offline.
- Drafts of long forms.
- Cache of rarely changing data (cities, categories) — pairs with TanStack Query's
initialData.
Do not use it for volatile UI state
Menu toggle, active tab, open modal — that's UI state that dies on reload. Zustand (see State) is far cheaper. Reserve the offline store for data that must persist.
Setup
import { createOfflineStore } from "tempest-react-sdk";
interface Notification {
message_id: string;
owner_id: string;
type: "NOTIFY" | "PAYMENT-SUCCESS";
message: string;
created_at: string;
read: boolean;
}
export const notificationsStore = createOfflineStore<Notification, string>({
databaseName: "TempestNotifications",
version: 1,
tableName: "notifications",
indexes: "&message_id, owner_id, read, created_at",
keyPath: "message_id",
ownerField: "owner_id",
});
indexes syntax: Dexie. & = unique primary key, commas separate additional indexes. keyPath points at the property used as the primary key (default "id"). ownerField enables multi-tenant scoping — see below.
Owner-scoping
When ownerField is configured, every read/write method honors an owner argument and persists that value on each record. This isolates each user's data in the same database — essential when two accounts use the app in the same browser:
const userId = "u-42";
// put stamps owner_id = "u-42" automatically
await notificationsStore.put(
{
message_id: "m-1",
owner_id: "", // overwritten by owner
type: "NOTIFY",
message: "Welcome",
created_at: new Date().toISOString(),
read: false,
},
userId,
);
// list only returns records for that owner
const mine = await notificationsStore.list(userId);
// count / clear / updateMany are also restricted to the owner
await notificationsStore.clear(userId); // does not affect other users
Without ownerField, the store is global
If you don't configure ownerField, the methods ignore the owner argument and operate over the whole table. Use the global store for data not tied to a user (a cities catalog, for example).
Full CRUD
const ownerId = "u-42";
// CREATE / UPDATE (upsert)
await notificationsStore.put(notification, ownerId);
await notificationsStore.bulkPut(notifications, ownerId); // single transaction
// READ
const one = await notificationsStore.get("m-1"); // by primary key
const recent = await notificationsStore.list(ownerId, {
orderBy: "created_at",
reverse: true,
limit: 50,
offset: 0,
filter: (n) => !n.read, // predicate applied after the index
});
const total = await notificationsStore.count(ownerId);
// Partial UPDATE
await notificationsStore.update("m-1", { read: true }); // by key
await notificationsStore.updateMany(ownerId, { read: true }); // all of the owner
// DELETE
await notificationsStore.delete("m-1"); // by key
await notificationsStore.clear(ownerId); // all of the owner
raw (Dexie's Table) and db (the Dexie instance) are exposed for advanced queries — multi-table transactions, complex where().and():
import { notificationsStore } from "./stores";
await notificationsStore.db.transaction("rw", notificationsStore.raw, async () => {
const unread = await notificationsStore.raw
.where("owner_id")
.equals("u-42")
.and((n) => !n.read)
.toArray();
console.log(`${unread.length} unread`);
});
Combining with SSE + TanStack Query
A common pattern: receive over SSE, persist offline, and use the local cache as initialData so the UI appears instantly on reload:
import { notificationsStore } from "./stores";
useEventStream<Notification>(`${API}/notifications/stream`, {
enabled: !!userId,
onMessage: ({ data }) => {
void notificationsStore.put(data, userId);
},
});
useQuery({
queryKey: ["notifications", userId],
queryFn: () => notificationsStore.list(userId, { orderBy: "created_at", reverse: true }),
});
createOfflineDatabase — several tables in one database
createOfflineStore gives each store a database of its own, which is the right shape for one isolated cache. It stops being right the moment the tables belong together: chats and their messages, an entity and its drafts, anything you would read or clear as a unit. Splitting those across databases costs the real transaction — Dexie runs one atomically only within a single database — and it scatters the version bump for a related change across two places.
import { createOfflineDatabase } from "tempest-react-sdk";
type Chat = { id: string; service_id: string; updated_at: string };
type Message = { id: string; service_chat_id: string; created_at: string };
const database = createOfflineDatabase<{ chats: Chat; messages: Message }>({
databaseName: "ChatDatabase",
version: 1,
tables: {
chats: { indexes: "&id, service_id, updated_at" },
messages: { indexes: "&id, service_chat_id, created_at" },
},
});
const chats = database.store<Chat>("chats");
const messages = database.store<Message>("messages");
// Both tables in one transaction — impossible across separate databases.
await database.db.transaction("rw", chats.raw, messages.raw, async () => {
await chats.put(chat);
await messages.bulkPut(pending);
});
Each store has exactly the surface of createOfflineStore (put, list, update, clear, count, raw, …); only ownership of the database changes. ownerField is configured per table, because a database commonly mixes per-user data with shared data.
Why the type goes on the call, not the schema
database.store<Chat>("chats") repeats a type the schema already declares. That is not a preference: Dexie's Table<T> expands UpdateSpec<T> over the keys of T, and deriving that from a still-generic TSchema[K] makes TypeScript give up with TS2589 ("excessively deep"). The name is still checked against the schema — a wrong table is a type error, and at runtime the call throws listing the available ones.
One database, one version
Change any table's indexes and you bump the version of the whole database. That is the cost of keeping them together — and precisely what stops two related databases from drifting apart.
Migrations
Bump version when you change indexes. Dexie runs migrations in-place. For a field rename or data shift, register an upgrader via the exposed Dexie instance:
notificationsStore.db.version(2).upgrade(async (tx) => {
await tx
.table("notifications")
.toCollection()
.modify((n) => {
n.read = n.read ?? false;
});
});
Forgetting to bump version breaks silently
Changing indexes without bumping version makes Dexie throw VersionError when opening the database. Always increment version alongside any schema change.
Recap
createOfflineStore({ databaseName, version, tableName, indexes, keyPath, ownerField })returns a typed CRUD over IndexedDB via Dexie (a direct SDK dependency).ownerFieldenables multi-tenant scoping:put/list/count/clear/updateManyrespectownerand isolate data per user.- CRUD:
put/bulkPut,get/list(withorderBy/reverse/limit/offset/filter),update/updateMany,delete/clear,count. rawanddbopen the door to advanced Dexie queries.- Bump
versionwhen changingindexes; usedb.version(N).upgrade(...)for data migrations.