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

event-sourced-collection

v0.0.6

Published

Event-sourced local-first database built on TanStack DB persistence

Downloads

625

Readme

event-sourced-collection

Event-sourced local-first database on top of TanStack DB persistence. Every insert, update, and delete is logged to a SQLite event table and synced to your backend when online.

Design decisions: ARCHITECTURE.md

Install

npm install event-sourced-collection @tanstack/db @tanstack/db-sqlite-persistence-core

Plus a platform package:

# Browser
npm install @tanstack/browser-db-sqlite-persistence

# React Native
npm install @tanstack/react-native-db-sqlite-persistence

Project structure

A minimal browser app splits setup by concern. collections.ts is the single source of truth for types, sync transport, collection registry, indexes, and the createBrowserEventSourcedDB call.

src/data-access-layer/
├── collections.ts       # types, push/pull, DB init → exports `ensureDb` + `db`
├── app-settings.ts      # seeds settings row, persists syncEnabled, calls setSyncEnabled()
└── sync-events.ts         # thin wrappers around db.sync() / db.manualSync()

src/hooks/common/
├── use-event-sourced-sync.ts   # periodic background sync (optional)
└── use-sync-enabled.ts         # reads settings.syncEnabled for hooks/UI

src/routes/…/settings/        # UI toggle for background sync (optional)
src/routes/…/layout.tsx         # call ensureAppSettings() once before data UI

| File | Role | | ---- | ---- | | collections.ts | Row types, collection registry, indexes, syncEnabled default, push/pull handlers, lazy singleton (ensureDb + db proxy) | | app-settings.ts | Seeds the "app" settings row, mirrors syncEnabled to db.setSyncEnabled() on startup and when the user toggles | | sync-events.ts | Keeps sync calls out of React; import from buttons, jobs, or hooks | | use-event-sourced-sync.ts | Polls manualSync() on an interval when DB is ready and sync is enabled | | use-sync-enabled.ts | Live-query helper for settings.syncEnabled (disable background sync in UI) | | Settings page | Optional UI for setSyncEnabled() — lets users opt out of push/pull | | App shell | await ensureAppSettings() before routes that read/write collections |

Imports in components: use db for reads/writes after startup. Call ensureDb() (or ensureAppSettings()) once at app mount.

Built-in collections (always present — do not register these ids):

  • db.collections.outbox — local mutations waiting to upload
  • db.collections.inbox — server events pulled to this device

Flow:

app mount → ensureAppSettings() → ensureDb() + seed settings + setSyncEnabled
                ↓
         db.collections.* available
                ↓
    useLiveQuery / insert / update / delete
                ↓
    useEventSourcedSync(dbReady && syncEnabled) or manual Sync now

Quick start

Copy the pattern below into collections.ts, then wire ensureDb() in your app shell.

1. Domain types

One type per collection you will register. These are your row shapes in SQLite.

type User = {
  id: string;
  name: string;
  email: string;
  createdAt: number;
};

type Todo = {
  id: string;
  userId: string;
  title: string;
  status: "pending" | "complete";
  createdAt: number;
  updatedAt: number;
};

type AppSettings = {
  id: string;
  theme: "light" | "dark";
  language: string;
  syncEnabled: boolean; // persisted preference; mirrored to db.setSyncEnabled()
};

2. Collection registry + typed DB handle

Keys here must match the collections option you pass in step 4. This gives you db.collections.users, etc. with full inference.

import type { CollectionDef, EventSourcedDB } from "event-sourced-collection";

type AppCollectionDefs = {
  users: CollectionDef<User, string>;
  todos: CollectionDef<Todo, string>;
  settings: CollectionDef<AppSettings, string>;
};

export type AppDb = EventSourcedDB<AppCollectionDefs>;

3. Sync transport

Implement push/pull against your API. These run during db.sync() — upload local outbox rows, download server events.

import type { OutboundEvent, PullResponse, PushResponse } from "event-sourced-collection";

const getAccessToken = () => localStorage.getItem("accessToken") ?? "";

async function pushEvents(events: ReadonlyArray<OutboundEvent>): Promise<PushResponse> {
  const response = await fetch("/api/sync/events", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${getAccessToken()}`,
    },
    body: JSON.stringify(events),
  });
  if (!response.ok) throw new Error(`Push failed: ${response.status}`);
  return response.json();
}

async function pullEvents({ since }: { since: number }): Promise<PullResponse> {
  const response = await fetch(`/api/sync/events?since=${since}`, {
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${getAccessToken()}`,
    },
  });
  if (!response.ok) throw new Error(`Pull failed: ${response.status}`);
  return response.json();
}

4. Create the database (collections.ts)

collections.ts is the single source of truth for row types, sync transport, collection registry, indexes, and DB init. Import db in components for reads/writes after startup. Call ensureDb() (or ensureAppSettings()) once at app mount before touching collections.

createBrowserEventSourcedDB (from event-sourced-collection/browser) wires the SQLite platform, registers collections, and returns a lazy singleton (ensureDb) plus a db proxy you can import anywhere. Collections are created here — there is no separate createUsersCollection() call.

The load callback is where you import platform packages. Keeping those imports in your app (not inside the library) means you choose @tanstack/react-db vs @tanstack/db, and SSR bundles stay clean because load runs on first ensureDb().

Full reference (matches the example app):

import { BasicIndex } from "@tanstack/db";
import { createBrowserEventSourcedDB } from "event-sourced-collection/browser";
import type {
  CollectionDef,
  EventSourcedDB,
  OutboundEvent,
  PullResponse,
  PushResponse,
} from "event-sourced-collection";

// --- Row types (one per collection) ---

export type User = { id: string; name: string; email: string; createdAt: number };
export type Todo = {
  id: string;
  userId: string;
  title: string;
  status: "pending" | "complete";
  createdAt: number;
  updatedAt: number;
};
// Singleton preferences row — use id "app" (see app-settings.ts)
export type AppSettings = {
  id: string;
  theme: "light" | "dark";
  language: string;
  syncEnabled: boolean;
};

type AppCollectionDefs = {
  users: CollectionDef<User, string>;
  todos: CollectionDef<Todo, string>;
  settings: CollectionDef<AppSettings, string>;
};

export type AppDb = EventSourcedDB<AppCollectionDefs>;

// --- Sync transport (runs when sync is enabled) ---

const getAccessToken = () => localStorage.getItem("accessToken") ?? "";

async function pushEvents(events: ReadonlyArray<OutboundEvent>): Promise<PushResponse> {
  const response = await fetch("/api/sync/events", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAccessToken()}` },
    body: JSON.stringify(events),
  });
  if (!response.ok) throw new Error(`Push failed: ${response.status}`);
  return response.json();
}

async function pullEvents({ since }: { since: number }): Promise<PullResponse> {
  const response = await fetch(`/api/sync/events?since=${since}`, {
    headers: { Accept: "application/json", Authorization: `Bearer ${getAccessToken()}` },
  });
  if (!response.ok) throw new Error(`Pull failed: ${response.status}`);
  return response.json();
}

// --- DB init: lazy singleton; db proxy forwards after ensureDb() ---

const { ensureDb, db } = createBrowserEventSourcedDB<AppCollectionDefs>({
  databaseName: "my-app.sqlite",
  debug: import.meta.env.DEV,

  collections: {
    users: {
      getKey: (user: User) => user.id,
      indexes: [{ select: (user: User) => user.id, indexType: BasicIndex, name: "by-id" }],
    },
    todos: {
      getKey: (todo: Todo) => todo.id,
      indexes: [
        { select: (todo: Todo) => todo.id, indexType: BasicIndex, name: "by-id" },
        { select: (todo: Todo) => todo.userId, indexType: BasicIndex, name: "by-user" },
        { select: (todo: Todo) => todo.status, indexType: BasicIndex, name: "by-status" },
        { select: (todo: Todo) => todo.title, indexType: BasicIndex, name: "by-title" },
      ],
    },
    settings: { getKey: (settings: AppSettings) => settings.id },
  },

  syncEnabled: true, // initial default; users can toggle at runtime (see app-settings.ts)
  sync: { pushEvents, pullEvents },

  load: async () => {
    const { createCollection } = await import("@tanstack/react-db");
    const {
      BrowserCollectionCoordinator,
      createBrowserWASQLitePersistence,
      openBrowserWASQLiteOPFSDatabase,
      persistedCollectionOptions,
    } = await import("@tanstack/browser-db-sqlite-persistence");

    return {
      openBrowserWASQLiteOPFSDatabase,
      createBrowserWASQLitePersistence,
      BrowserCollectionCoordinator,
      createCollection,
      persistedCollectionOptions,
    };
  },
});

export { db, ensureDb };

The handle is { ensureDb, db, close }:

  • ensureDb() — runs load + setup once (deduped). Throws outside a browser environment.
  • db — proxy that forwards after ensureDb(); throws if used before init.
  • close() — disposes the engine and SQLite platform; next ensureDb() re-initializes.

After setup you get db.collections.users, db.collections.todos, db.collections.settings, plus built-in outbox and inbox (see Project structure).

Collection indexes

TanStack DB indexes are opt-in. Declare them on each collection in the collections registry — the package calls collection.createIndex(select, { name?, indexType }) for you and keeps them registered across the collection lifecycle (including after SQLite hydration when the collection becomes ready again).

Requirements

  • Import BasicIndex from @tanstack/db and pass it as indexType on every index entry.
  • The select callback must return the same field you filter, join, or orderBy on in useLiveQuery.
  • Use name when a collection has more than one index.

Shape

import { BasicIndex } from "@tanstack/db";
import type { CollectionDef } from "event-sourced-collection";

type SavedMovieRef = { movieId: number; title: string; addedAt: number };

// In your AppCollectionDefs / collections registry:
favorites: CollectionDef<SavedMovieRef, number>;
collections: {
  todos: {
    getKey: (todo: Todo) => todo.id,
    indexes: [
      { select: (todo: Todo) => todo.id, indexType: BasicIndex, name: "by-id" },
      { select: (todo: Todo) => todo.userId, indexType: BasicIndex, name: "by-user" },
      { select: (todo: Todo) => todo.status, indexType: BasicIndex, name: "by-status" },
    ],
  },

  favorites: {
    getKey: (item: SavedMovieRef) => item.movieId,
    indexes: [{ select: (item) => item.movieId, indexType: BasicIndex, name: "by-movie-id" }],
  },

  settings: { getKey: (settings: AppSettings) => settings.id },
},

Joins — index the field on the joined collection (the right-hand side), not the driving collection:

import { eq, useLiveQuery } from "@tanstack/react-db";
import { db } from "./collections";
import { moviesCollection } from "./movies-collection";

// favorites needs an index on movieId because the join is eq(movie.id, favorite.movieId)
const { data } = useLiveQuery((q) =>
  q
    .from({ movie: moviesCollection })
    .leftJoin({ favorite: db.collections.favorites }, ({ movie, favorite }) =>
      eq(movie.id, favorite.movieId),
    )
    .select(({ movie, favorite }) => ({ movie, isFavorite: favorite !== undefined })),
);

Without that index, TanStack DB logs a warning and falls back to loading the entire favorites collection for each join.

Filters and sort — match indexes to your queries:

| Query pattern | Index select | | ------------- | -------------- | | eq(todo.userId, userId) | (todo) => todo.userId | | eq(todo.status, "pending") | (todo) => todo.status | | orderBy(({ todo }) => todo.title) | (todo) => todo.title |

What you do not need

  • Do not call collection.createIndex() yourself after ensureDb() when using this package — declare indexes in collections instead.
  • Do not enable autoIndex: 'eager' for collections created here; explicit indexes + BasicIndex is the supported pattern.

Debugging — after await ensureDb(), check that indexes exist:

db.collections.favorites.getIndexMetadata();
// non-empty array means indexes are registered

5. App settings (app-settings.ts)

Seed a singleton settings row and keep syncEnabled in sync with db.setSyncEnabled():

import type { AppSettings } from "./collections";
import { db, ensureDb } from "./collections";

export const APP_SETTINGS_ID = "app";

const DEFAULT_APP_SETTINGS: AppSettings = {
  id: APP_SETTINGS_ID,
  theme: "dark",
  language: "en",
  syncEnabled: true,
};

export async function ensureAppSettings(): Promise<AppSettings> {
  const database = await ensureDb();
  const existing = database.collections.settings.get(APP_SETTINGS_ID);

  if (!existing) {
    await database.collections.settings.insert(DEFAULT_APP_SETTINGS).isPersisted.promise;
    database.setSyncEnabled(DEFAULT_APP_SETTINGS.syncEnabled);
    return DEFAULT_APP_SETTINGS;
  }

  const syncEnabled = existing.syncEnabled ?? true;

  if (existing.syncEnabled === undefined) {
    await database.collections.settings
      .update(APP_SETTINGS_ID, (draft) => {
        draft.syncEnabled = true;
      })
      .isPersisted.promise;
  }

  database.setSyncEnabled(syncEnabled);
  return { ...existing, syncEnabled };
}

export async function setSyncEnabled(enabled: boolean): Promise<void> {
  const database = await ensureDb();
  database.setSyncEnabled(enabled);

  const existing = database.collections.settings.get(APP_SETTINGS_ID);
  if (!existing) {
    await database.collections.settings.insert({ ...DEFAULT_APP_SETTINGS, syncEnabled: enabled })
      .isPersisted.promise;
    return;
  }

  await database.collections.settings
    .update(APP_SETTINGS_ID, (draft) => {
      draft.syncEnabled = enabled;
    })
    .isPersisted.promise;
}

6. Wire the app shell

Call ensureAppSettings() once when the app mounts (it calls ensureDb() internally):

import { useEffect, useState } from "react";
import { ensureAppSettings } from "./data-access-layer/app-settings";
import { useEventSourcedSync } from "./hooks/common/use-event-sourced-sync";
import { useSyncEnabled } from "./hooks/common/use-sync-enabled";

function AppShell() {
  const [dbReady, setDbReady] = useState(false);

  useEffect(() => {
    void ensureAppSettings().then(() => setDbReady(true));
  }, []);

  const syncEnabled = useSyncEnabled(dbReady);
  useEventSourcedSync(dbReady && syncEnabled);

  if (!dbReady) return <Loading />;
  return <YourRoutes />;
}

Optional sync helpers:

// sync-events.ts
import { ensureDb } from "./collections";

export async function manualSyncEvents() {
  const database = await ensureDb();
  return database.manualSync();
}

// use-event-sourced-sync.ts — polls manualSync when enabled
export function useEventSourcedSync(enabled: boolean) {
  return useQuery({
    queryKey: ["sync"],
    queryFn: () => manualSyncEvents(),
    enabled,
    refetchInterval: 60_000,
  });
}

// use-sync-enabled.ts — live-query settings.syncEnabled for UI
export function useSyncEnabled(dbReady: boolean) {
  const { data = [] } = useLiveQuery(
    (query) =>
      query
        .from({ setting: db.collections.settings })
        .where(({ setting }) => eq(setting.id, APP_SETTINGS_ID)),
    [dbReady],
  );
  if (!dbReady) return true;
  return data[0]?.syncEnabled ?? true;
}

Settings UI toggle (calls setSyncEnabled from app-settings.ts):

import { setSyncEnabled } from "@/data-access-layer/app-settings";
import { useSyncEnabled } from "@/hooks/common/use-sync-enabled";

function SettingsPage() {
  const syncEnabled = useSyncEnabled(true);

  return (
    <Switch
      checked={syncEnabled}
      onCheckedChange={(checked) => void setSyncEnabled(checked)}
    />
  );
}

Disable manual Sync now when sync is off: const syncEnabled = useSyncEnabled(true) and pass it to your button's disabled prop.

7. Write data

Use collections like normal TanStack DB. Mutations are logged to the event store automatically.

import { db } from "./data-access-layer/collections";

const userId = crypto.randomUUID();

db.collections.users.insert({
  id: userId,
  name: "Alice",
  email: "[email protected]",
  createdAt: Date.now(),
});

db.collections.todos.insert({
  id: crypto.randomUUID(),
  userId,
  title: "Buy groceries",
  status: "pending",
  createdAt: Date.now(),
  updatedAt: Date.now(),
});

db.collections.settings.insert({
  id: "app",
  theme: "dark",
  language: "en",
  syncEnabled: true,
});

db.collections.todos.update("todo-id", (draft) => {
  draft.status = "complete";
  draft.updatedAt = Date.now();
});

db.collections.todos.delete("todo-id");

For settings, use a fixed id (e.g. "app") as a singleton row. Prefer ensureAppSettings() at startup instead of inserting manually:

db.collections.settings.update("app", (draft) => {
  draft.theme = "light";
});

8. Read data

import { useLiveQuery } from "@tanstack/react-db";
import { db } from "./data-access-layer/collections";

function TodoList() {
  const { data: todos = [] } = useLiveQuery((q) =>
    q.from({ todo: db.collections.todos }),
  );

  const { data: users = [] } = useLiveQuery((q) =>
    q.from({ user: db.collections.users }),
  );

  const { data: settings = [] } = useLiveQuery((q) =>
    q.from({ setting: db.collections.settings }),
  );

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          {todo.title} — {users.find((u) => u.id === todo.userId)?.name}
        </li>
      ))}
    </ul>
  );
}

9. Sync

import { ensureDb } from "./data-access-layer/collections";

const db = await ensureDb();
await db.sync();

window.addEventListener("online", () => void db.sync());

Or use a periodic hook (see step 6). Respect syncEnabled — background sync should stay off when the user opts out.

Inspecting sync state

outbox and inbox are queryable collections. Each row carries a sync flag:

  • outboxsync: false until the event has been pushed to the server successfully. sync: true means the server accepted it (and assigned a globalSeq). Outbox rows also include syncStatus, attemptCount, lastAttemptAt, lastError, lastErrorCode, and retryable for push diagnostics.
  • inboxsync: false until the event has been replayed on top of local data. sync: true means it has been applied to the relevant collection.
import { useLiveQuery } from "@tanstack/react-db";
import { eq } from "@tanstack/db";
import { db } from "./data-access-layer/collections";

function SyncStatus() {
  const { data: unpushed = [] } = useLiveQuery((q) =>
    q.from({ e: db.collections.outbox }).where(({ e }) => eq(e.sync, false)),
  );

  const { data: incoming = [] } = useLiveQuery((q) =>
    q.from({ e: db.collections.inbox }),
  );

  return (
    <p>
      {unpushed.length} pending upload · {incoming.length} received
    </p>
  );
}

How it works

| Step | What happens | | ---- | ------------ | | Register collections | collections: { users, todos, settings } in createBrowserEventSourcedDB (or low-level createEventSourcedDB) | | Access them | db.collections.users, etc. | | Write data | .insert(), .update(), .delete() | | Log mutations | Each write appends a row to db.collections.outbox (sync: false) | | Read data | useLiveQuery against those collections | | Sync | await db.sync() pushes the outbox and pulls into the inbox |

On push, confirmed outbox rows flip to sync: true with their server globalSeq. On pull, new server events are written to inbox (sync: false), replayed into the target collection via acceptMutations, then flipped to sync: true. The client's own events come back on pull and are written to inbox as already applied. The pull cursor is derived from the highest synced globalSeq in inbox — there is no separate cursor table.

Step by step:

  1. User mutates db.collections.todos → row appended to local outbox (sync: false).
  2. manualSync() or background hook pushes outbox → POST /api/sync/events.
  3. Server assigns globalSeq → outbox rows flip to sync: true.
  4. Pull fetches newer events → GET /api/sync/events?since=<cursor>.
  5. Remote events land in inbox, replay into collections, flip to sync: true.

Server contract

POST /api/sync/events

Request: array of outbound events (eventId, collectionId, type, key, payload, timestamp).

Response:

{
  "confirmed": [{ "eventId": "...", "globalSeq": 100 }],
  "failed": [{ "eventId": "...", "message": "Validation failed", "code": "VALIDATION_ERROR", "retryable": false }]
}

GET /api/sync/events?since={globalSeq}

Response:

{
  "events": [{ "globalSeq": 102, "eventId": "...", "collectionId": "todos", "type": "insert", "key": "...", "payload": {}, "timestamp": 0, "cursor": "102" }],
  "cursor": "102",
  "hasMore": false
}

Minimal PostgreSQL schema:

CREATE TABLE events (
  global_seq        BIGSERIAL PRIMARY KEY,
  event_id          TEXT NOT NULL UNIQUE,
  collection_id     TEXT NOT NULL,
  type              TEXT NOT NULL CHECK (type IN ('insert', 'update', 'delete')),
  key               TEXT NOT NULL,
  payload           JSONB NOT NULL,
  client_timestamp  BIGINT NOT NULL
);

CREATE INDEX idx_events_global_seq ON events (global_seq);

Simulate another device

POST a fake event directly to your sync server to mimic a push from another client. Use a unique eventId — it must not already exist in the receiving device's local outbox.

curl -X POST http://localhost:3000/api/sync/events \
  -H "Content-Type: application/json" \
  -d '[{
    "eventId": "019c0000-0000-7000-8000-remote-demo",
    "collectionId": "todos",
    "type": "insert",
    "key": "remote-todo-demo",
    "payload": {
      "id": "remote-todo-demo",
      "userId": "remote-user",
      "title": "From another device",
      "status": "pending",
      "createdAt": 1730000000000,
      "updatedAt": 1730000000000
    },
    "timestamp": 1730000000000
  }]'

Then call manualSync() or db.sync() on the client. The event appears in inbox and the todo is replayed locally.

Check server state:

curl "http://localhost:3000/api/sync/events?since=0"

Sync Options

You can run fully offline by omitting sync. In that mode local mutations still append to outbox, and sync() is a no-op.

Use typed functions when your app already has RPC/server-function clients:

import type { PullResponse, PushResponse } from "event-sourced-collection";

const sync = {
  pushEvents: async (events): Promise<PushResponse> => {
    return typedRpc.events.push({ events });
  },
  pullEvents: async ({ since }): Promise<PullResponse> => {
    return typedRpc.events.pull({ since });
  },
};

const db = await createEventSourcedDB({ sync, /* ... */ });

Use URLs when you want the built-in HTTP adapter:

const db = await createEventSourcedDB({
  sync: {
    pushUrl: "/api/events",
    pullUrl: "/api/events",
    headers: () => ({ Authorization: `Bearer ${getAccessToken()}` }),
  },
  /* ... */
});

You can provide only pushEvents/pushUrl for upload-only sync or only pullEvents/pullUrl for download-only sync. If both a function and URL are provided for the same direction, the function is used.

For existing integrations, the legacy shapes still work:

import type { SyncTransport } from "event-sourced-collection";

const transport: SyncTransport = {
  async push(events) { /* return confirmed event ids + globalSeq */ },
  async pull(since) { /* return { events, cursor, hasMore } */ },
};

const db = await createEventSourcedDB({ sync: transport, /* ... */ });

Opting out of sync

syncEnabled defaults to true on createBrowserEventSourcedDB. When disabled:

  • sync() skips push/pull (returns { pushed: 0, pulled: 0, errors: [] })
  • manualSync() still replays pending inbox events locally but does not hit the network
  • Background sync hooks should pass dbReady && syncEnabled as their enabled flag

Toggle at runtime:

const database = await ensureDb();
database.setSyncEnabled(false);

Recommended pattern: persist the preference in your settings collection and mirror it on startup — see App settings (ensureAppSettings + setSyncEnabled). That keeps the Settings UI, background sync hook, and engine in sync.

await setSyncEnabled(false);
// updates settings row + calls db.setSyncEnabled(false)

React Native

Same helper shape via createReactNativeEventSourcedDB (from event-sourced-collection/react-native). React Native can't auto-open the database, so load returns the database driver alongside the platform functions.

import { createReactNativeEventSourcedDB } from "event-sourced-collection/react-native";

const { ensureDb, db } = createReactNativeEventSourcedDB<AppCollectionDefs>({
  collections: {
    users: { getKey: (user: User) => user.id },
    todos: { getKey: (todo: Todo) => todo.id },
  },
  sync: { pushEvents, pullEvents },
  load: async () => {
    const { createCollection } = await import("@tanstack/react-native-db");
    const { createReactNativeSQLitePersistence, persistedCollectionOptions } = await import(
      "@tanstack/react-native-db-sqlite-persistence"
    );
    const { openDatabase } = await import("react-native-op-sqlite");

    return {
      createReactNativeSQLitePersistence,
      persistedCollectionOptions,
      createCollection,
      database: openDatabase({ name: "my-app.sqlite" }),
    };
  },
});

export { db, ensureDb };

Insert, update, delete, useLiveQuery, and sync() work the same as in the browser.

Cleanup

Call the handle's close() to dispose the engine and release the platform:

await close();

If you wired things up by hand with the low-level createEventSourcedDB + createBrowserPlatform, dispose them directly instead:

db.dispose();
await platform.close();

API

createBrowserEventSourcedDB(config) / createReactNativeEventSourcedDB(config)

The recommended entry points. Import from event-sourced-collection/browser or event-sourced-collection/react-native.

| Option | Required | Description | | ------ | -------- | ----------- | | collections | Yes | Collection definitions (getKey, optional schemaVersion, optional indexes[]) — must not use the reserved ids outbox/inbox | | load | Yes | Async callback returning the platform modules + createCollection + persistedCollectionOptions. Keeps platform/framework imports in your app | | databaseName | Browser only | SQLite database file name | | coordinatorDbName | No (browser) | Multi-tab coordinator name; defaults to databaseName without the .sqlite suffix | | sync | No | Offline by default. Same shapes as createEventSourcedDB | | syncEnabled | No | Default true. When false, push/pull are skipped until re-enabled | | schemaVersion | No | Default 1 | | debug | No | boolean or a custom logger |

Returns { ensureDb, db, close }:

  • ensureDb() — runs load + setup once (deduped), returns the ready DB. The browser helper throws outside a browser environment.
  • db — proxy that forwards to the instance after init and throws if used before ensureDb().
  • close() — disposes the engine (and closes the SQLite platform on browser); the next ensureDb() re-initializes.

createLazySingleton(factory, options?)

Dependency-free building block behind the helpers. Wraps an async factory into { ensure, proxy, reset }: ensure() runs the factory once (deduped, retried after failure), proxy forwards to the instance and throws until initialized, reset() clears it. Accepts an optional guard and notInitializedMessage.

createEventSourcedDB(config)

Low-level core for full control or non-standard platforms. The helpers above call this for you.

| Option | Required | Description | | ------ | -------- | ----------- | | persistence | Yes | TanStack DB persistence config | | createCollection | Yes | TanStack DB createCollection | | persistedCollectionOptions | Yes | From your platform package | | collections | Yes | Collection definitions (getKey, optional schemaVersion, optional indexes[]) — must not use the reserved ids outbox/inbox | | sync | No | Offline by default. Accepts typed pushEvents/pullEvents, URL pushUrl/pullUrl, legacy URL push/pull, or legacy SyncTransport | | syncEnabled | No | Default true. When false, push/pull are skipped until re-enabled | | schemaVersion | No | Default 1 |

Returns { collections, sync, manualSync, getSyncEnabled, setSyncEnabled, dispose }, where collections includes your registered collections plus the built-in outbox and inbox.

  • getSyncEnabled() / setSyncEnabled(enabled) — runtime toggle for push/pull (default true at init via syncEnabled config option).

createBrowserPlatform(deps, config)

Import from event-sourced-collection/browser. Sets up browser SQLite + multi-tab coordinator.

createReactNativePlatform(deps, config)

Import from event-sourced-collection/react-native. Sets up React Native SQLite persistence.