event-sourced-collection
v0.0.6
Published
Event-sourced local-first database built on TanStack DB persistence
Downloads
625
Maintainers
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-corePlus a platform package:
# Browser
npm install @tanstack/browser-db-sqlite-persistence
# React Native
npm install @tanstack/react-native-db-sqlite-persistenceProject 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 uploaddb.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 nowQuick 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()— runsload+ setup once (deduped). Throws outside a browser environment.db— proxy that forwards afterensureDb(); throws if used before init.close()— disposes the engine and SQLite platform; nextensureDb()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
BasicIndexfrom@tanstack/dband pass it asindexTypeon every index entry. - The
selectcallback must return the same field you filter, join, ororderByon inuseLiveQuery. - Use
namewhen 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 afterensureDb()when using this package — declare indexes incollectionsinstead. - Do not enable
autoIndex: 'eager'for collections created here; explicitindexes+BasicIndexis the supported pattern.
Debugging — after await ensureDb(), check that indexes exist:
db.collections.favorites.getIndexMetadata();
// non-empty array means indexes are registered5. 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:
- outbox —
sync: falseuntil the event has been pushed to the server successfully.sync: truemeans the server accepted it (and assigned aglobalSeq). Outbox rows also includesyncStatus,attemptCount,lastAttemptAt,lastError,lastErrorCode, andretryablefor push diagnostics. - inbox —
sync: falseuntil the event has been replayed on top of local data.sync: truemeans 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:
- User mutates
db.collections.todos→ row appended to local outbox (sync: false). manualSync()or background hook pushes outbox →POST /api/sync/events.- Server assigns
globalSeq→ outbox rows flip tosync: true. - Pull fetches newer events →
GET /api/sync/events?since=<cursor>. - 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 && syncEnabledas theirenabledflag
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()— runsload+ 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 beforeensureDb().close()— disposes the engine (and closes the SQLite platform on browser); the nextensureDb()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 (defaulttrueat init viasyncEnabledconfig 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.
