data-store
Offline-first IndexedDB datastore with a persistent outbox, push/pull sync, conflict resolution, multi-table transactions and a typed filter DSL — framework-agnostic, no vendor lock-in.
Built for apps that must keep working with no connection at all — and reconcile honestly when it returns. Here IndexedDB is the single source of truth, every mutation commits atomically with its queued upload, and everything the server has not yet acknowledged survives reloads, crashes and going offline for a week.
The transport is yours: a table declares plain
push / pull functions and the store
drives them — FIFO uploads with exponential backoff that parks
while offline, checkpoint-based delta pulls, and a conflict path
built on one typed error instead of a vendor contract. Swap
GraphQL for REST for gRPC without touching a line of store code.
The push and pull engines are built on
retry-request, which reads the online state from
network-connection. NetworkConnection.init() must run before
store.start() — otherwise start()
rejects immediately with a clear error instead of pretending
the network is fine. See Install.
Getting started
Install#
npm i @dmytromykhailiuk/data-store
Ships ESM and CJS with type declarations for both, Node ≥ 18 or
any browser with IndexedDB. Runtime dependencies:
idb and four sibling packages —
execution-blocker (FIFO locks),
message-queue (outbox dispatch),
network-connection and retry-request
(network-aware retries).
Initialize the network layer once, at app startup, before the store starts:
import { NetworkConnection } from "@dmytromykhailiuk/network-connection";
await NetworkConnection.init("/healthcheck.txt", { pingInterval: 15_000 });
Getting started
Quick start#
import { NetworkConnection } from "@dmytromykhailiuk/network-connection";
import { ConflictError, createDataStore, defineTable } from "@dmytromykhailiuk/data-store";
interface Issue {
id: string;
eventId: string;
severity: number;
status: "OPEN" | "RESOLVED";
_version?: number;
}
interface SyncParams {
eventId?: string;
}
// 1. Declare tables: model type + the slice of params they consume.
const store = createDataStore<SyncParams>({
name: "InspectorDataStore",
schemaVersion: 1,
ignoreFields: ["_version", "updatedAt", "owner"],
tables: {
Issue: defineTable<Issue, SyncParams>({
primaryKey: "id",
indexes: { byEvent: { key: "eventId" } },
scope: {
key: (params) => params.eventId ?? null,
keep: (issue, params) => issue.eventId === params.eventId,
},
push: {
create: async (issue) => (await api.createIssue(issue)).data,
update: async (issue) => {
try {
return (await api.updateIssue(issue)).data;
} catch (error) {
if (api.isVersionMismatch(error)) {
throw new ConflictError({ remote: api.remoteOf(error) });
}
throw error; // transport errors retry with backoff on their own
}
},
delete: async (issue) => {
await api.deleteIssue(issue.id, issue._version);
},
},
pull: {
fetch: async ({ params, checkpoint, signal }) => {
const page = await api.issuesByEvent(params.eventId!, { since: checkpoint, signal });
return { items: page.items, checkpoint: page.startedAt, done: !page.nextToken };
},
fetchOne: (id) => api.getIssue(id),
deletedKey: undefined, // set to "_deleted" for server tombstones
},
merge: (local, remote) => ({ ...remote, status: local.status }),
}),
},
});
// 2. Start: opens IndexedDB, restores the outbox, pulls the current scope.
await NetworkConnection.init("/healthcheck.txt");
await store.start({ params: { eventId: route.eventId } });
await store.whenSynced();
// 3. Work with tables — everything below survives reloads and offline.
const issues = store.table("Issue");
await issues.put({ id: crypto.randomUUID(), eventId: route.eventId!, severity: 3, status: "OPEN" });
await issues.update(issueId, { status: "RESOLVED" });
const open = await issues.query({ status: { eq: "OPEN" }, severity: { ge: 2 } });
// 4. React to changes and to the sync engine.
issues.subscribe({ eventId: { eq: route.eventId! } }, ({ type, item, origin }) => {
render(type, item, origin); // origin: "local" | "pull" | "push" | "merge" | "evict"
});
await store.whenUploaded(); // outbox is empty — safe to log out
Concepts
How it works#
Five rules explain everything the store does. Everything else in this documentation is a consequence of them.
- IndexedDB is the single source of truth. There is no in-memory mirror to drift out of sync. Reads are plain readonly transactions — consistent snapshots by definition.
-
Records are stored wrapped:
{ key, data, meta }. Your fields never mix with the bookkeeping (state, revision counter, sync scope, last error). -
One write path. Every mutation — CRUD, a
transaction, an applied pull page, a push acknowledgement —
acquires the table's FIFO lock and runs one
readwriteIndexedDB transaction spanning the data and the outbox. Locks are never held across the network. - The outbox is data. A queued upload commits in the same transaction as the record it belongs to, and is rebuilt from disk on every start. The in-memory queue is just a dispatcher.
-
The transport is yours. The store calls the
push/pullfunctions a table declares and understands exactly two special errors:ConflictErrorandFatalPushError.
Record states#
Every record carries a meta.state describing where
it stands relative to the server:
| State | Meaning | Leaves it when |
|---|---|---|
synced |
Identical to the last acknowledged server state. | A local mutation or a newer pull arrives. |
pending |
Has a local mutation waiting in the outbox. | The push pipeline claims it. |
pushing |
Its mutation is in flight right now. | The push succeeds, fails, or is outrun by a newer save. |
error |
The push gave up: a FatalPushError, an
exhausted retry budget, or a second conflict in a row.
|
retryFailed(), resolveFailed(),
or any new local mutation.
|
local |
Never pushed: records of local: true tables
and writes made with { local: true }.
|
A non-local write promotes it into the outbox. |
A deletion of a server-known record does not remove it
immediately: the record becomes a tombstone — hidden
from every read, still holding its place in the outbox — and is
physically removed only when the server confirms the delete.
Records the server never saw are simply dropped, along with their
queued create.
The outbox#
Every syncable mutation writes an entry into the internal
_outbox store — in the same IndexedDB transaction as
the data, so there is no crash window where a record exists but
its upload does not (or the reverse). Entries are FIFO by a
monotonic sequence number; one entry per record.
Several mutations of one record coalesce into one entry that keeps its place in the queue:
| Queued | Then you… | Queue now holds |
|---|---|---|
create | update | create with the newest data |
create | delete | nothing — the record is dropped locally |
update | delete | delete |
delete | put (same key) | update — the resurrection case |
Dispatching runs on a single-handler queue: strict FIFO, one mutation in flight at a time, so a parent record always lands before the child that references it. Transport retries happen inside the handler — a flaky record never loses its place and never lets a younger mutation overtake it.
Before the network call the pipeline snapshots the record's
revision counter; after the call it re-checks it under the
table lock. If you saved again mid-flight, the server echo is
stale — it is discarded, the record returns to
pending, and the newer data pushes next. Saving
during an upload can never lose your newest write.
Pull & checkpoints#
A pull downloads a scope page by page:
fetch() returns items plus an optional
checkpoint and done flag, and is called
again with the new checkpoint until done. Each page
is applied in one transaction together with its
checkpoint — a crash mid-sync resumes from the last durable page
instead of starting over or marking a half-pulled scope synced.
The checkpoint persists per (table, scope) and is
handed back on the next sync of the same scope — that is your
delta-sync cursor (startedAt, a nextToken,
a timestamp — whatever your API paginates by). Records with
unpushed local changes are rebased by default: the
table's merge runs over (local, remote) and the
record stays queued with the merged data — so its eventual
push carries fresh server-managed fields instead of a stale
base (see Pull handlers).
Sync scopes#
A scope names which slice of server data a table mirrors
right now — "issues of event 42", "objects of site A". The
scope.key function derives it from the store params;
null means "this table cannot sync yet" (a missing
route param, say). When setParams changes a
table's key, the table re-syncs: records failing
scope.keep are evicted (never the ones with unpushed
changes), and the new scope pulls — from its own checkpoint, if
it ever synced before.
Guides
Reading & writing#
const issues = store.table("Issue");
// Reads — consistent snapshots straight from IndexedDB.
await issues.get(id); // T | undefined
await issues.getAll({ limit: 100 });
await issues.getByIndex("byEvent", eventId);
await issues.has(id);
await issues.count({ status: { eq: "OPEN" } });
await issues.meta(id); // RecordMeta | undefined
// Writes — serialized per table, atomic with the outbox.
await issues.put(issue); // insert or replace → queues create/update
await issues.update(id, { status: "RESOLVED" }); // partial merge
await issues.update(id, (cur) => ({ ...cur, n: cur.n + 1 })); // atomic RMW
await issues.delete(id); // tombstone → queues delete
await issues.evict(id); // local removal, tells the server nothing
await issues.clear(); // local wipe of the table + its queue
update() and delete() of a key the
table does not hold reject with
RecordNotFoundError — loudly and immediately. The
functional form of
update() is an atomic read-modify-write: fifty
parallel (n) => n + 1 updates produce exactly
+50, because each one runs under the table lock in its own
IndexedDB transaction.
Any write accepts { local: true }: the data
changes locally but nothing is queued for upload. A later
normal write promotes the record — as a create if
the server never saw it, as an update otherwise
(the store tracks this in meta.serverKnown).
Guides
Queries & the filter DSL#
query(), count() and filtered
subscriptions accept either a plain
predicate or a typed, serializable filter object. Filters run
against full records — there is no hidden
projection that silently sees only some fields.
// The DSL — serializable, type-checked per field.
await issues.query({
and: [
{ status: { in: ["OPEN", "TRIAGED"] } },
{ severity: { between: [2, 4] } },
{ or: [{ title: { contains: query } }, { id: { beginsWith: query } }] },
{ not: { assignee: { eq: null } } },
],
}, { limit: 50 });
// A predicate — for anything the DSL cannot say.
await issues.query((issue) => issue.tags.length > 3 && isHot(issue));
| Operator | Applies to | Meaning |
|---|---|---|
eq / ne | any field | structural equality / inequality |
lt le gt ge | string, number | comparison (strings — lexicographic) |
between | string, number | inclusive [low, high] |
in / notIn | any field | membership in a list |
contains / notContains | string, arrays | substring / element membership |
beginsWith | string | prefix match |
and or not | — | logical nodes, nest freely |
By default a query is purely local — a readonly
IndexedDB snapshot, no network ever. With
{ remote: true } it first round-trips through the
table's pull.query handler: the serializable filter
travels to your backend, the returned records are applied with
full pull semantics (upserted as synced, queued
records rebased, tombstones honoured), and only then the query
runs locally — so the answer includes both the fresh server rows
and anything queued locally.
// Table config: the server-side counterpart of query().
pull: {
fetch: ...,
query: async (filter, { params, signal }) =>
(await api.searchIssues(filter, { signal })).items,
},
// Local by default:
await issues.query({ status: { eq: "OPEN" } });
// Fetch → persist → query locally:
await issues.query({ status: { eq: "OPEN" } }, { remote: true });
// Best-effort fallback when the request may fail:
const rows = await issues
.query(filter, { remote: true })
.catch(() => issues.query(filter));
With a function filter and remote: true the
handler receives undefined — a closure cannot be
serialized. Fetch a sensible superset there; the predicate
still filters the final, local result.
Several fields in one object form an implicit AND. The operators
are checked against the field's type at compile time —
{ severity: { beginsWith: "x" } } does not build.
At runtime an invalid condition throws a loud
SchemaError; the AppSync-style silent pass-through
(a scalar where an operator object belongs quietly matching
everything) is gone. compileFilter /
matchesFilter are exported, so a
pull.fetch can reuse the exact local semantics
server-side.
Guides
Subscriptions#
// Every change of one table…
const off = issues.subscribe(({ type, key, item, origin }) => {
// type: "created" | "updated" | "deleted" (item is null for deleted)
// origin: "local" | "pull" | "push" | "merge" | "evict"
});
// …or only the slice you render.
issues.subscribe({ eventId: { eq: currentEvent } }, rerenderList);
// Store-wide events:
store.on("change", handler); // all tables
store.on("syncStart", handler); // { table, scopeKey }
store.on("syncEnd", handler); // { table, scopeKey, pulled }
store.on("syncError", handler); // { table, scopeKey, error }
store.on("pushSuccess", handler); // { table, key, op }
store.on("pushError", handler); // { table, key, op, error, willRetry }
store.on("conflict", handler); // { table, key, resolution }
store.on("paramsChanged", handler); // { params, resyncedTables }
store.on("outboxDrained", handler); // {}
off(); // every subscribe()/on() call returns its unsubscribe function
table.subscribe() and store.on() both
hand back a plain () => void — call it to
detach. No subscription objects, no off(event,
listener) bookkeeping, nothing to keep besides the
returned function.
// A component subscribes on mount…
const offList = issues.subscribe({ eventId: { eq: id } }, rerenderList);
const offBadge = store.on("outboxDrained", hideSavingBadge);
const offErrors = store.on("pushError", ({ table, key, willRetry }) => {
if (!willRetry) showFailedToast(table, key);
});
// …and detaches on unmount by calling what it got back.
onUnmount(() => {
offList();
offBadge();
offErrors();
});
Events fire only after their transaction commits — a listener
never observes state that later rolled back. The
origin field tells you why a change
happened: a common pattern is ignoring "push"
echoes (your UI already shows that data) while reacting to
"pull" and "merge" — both the pull
loop and applyRemote() emit
"pull"-origin events. A listener that
throws is reported to onError and never breaks the
other listeners. Deleted events carry item: null,
so filtered subscriptions always deliver them — a gone record
cannot be re-checked against the filter.
For await-shaped code there are promise forms:
store.whenStarted(), store.whenSynced(),
store.whenUploaded(), and per table —
table.whenSynced() / table.whenUploaded().
whenUploaded() resolves when nothing is queued or in
flight; records parked in the error state do not
block it — they are surfaced through
status.failed and resolveFailed.
Guides
Transactions#
store.transaction() runs your callback against one
IndexedDB transaction spanning the listed tables and the
outbox: every write inside — data and queued pushes alike —
commits or rolls back as a single unit. Events fire only after
the commit; a thrown error rolls everything back, including the
queue.
await store.transaction(["Event", "Issue", "Image"], async (tx) => {
const event = await tx.table("Event").get(eventId);
if (!event) throw new Error("event vanished"); // → full rollback
await tx.table("Event").update(eventId, { status: "COMPLETED" });
for (const issue of resolved) await tx.table("Issue").delete(issue.id);
await tx.table("Image").put(finalImage);
});
Table locks are acquired in sorted order, so two overlapping
transactions can never deadlock — they serialize. The
transaction view supports get, getAll,
put, update, delete —
reads inside see the transaction's own writes.
IndexedDB auto-commits the moment the microtask queue drains
without a pending request. Awaiting a fetch, a
timer or another store call inside the callback kills the
transaction; the next operation throws
TransactionInactiveError with an explanation.
Fetch first, then transact.
Guides
Push handlers#
A table declares up to three plain async functions. Whatever
create / update resolve with — the
server echo, typically carrying a fresh version — replaces the
local data without marking the record dirty; resolve
undefined to keep the local data as-is.
push: {
create: (item, { attempt, params, signal }) => api.create(item, { signal }),
update: (item, ctx) => api.update(item),
delete: async (item, ctx) => { await api.remove(item.id, item._version); },
retry: {
maxRetries: Infinity, // transport errors only — see below
retryBaseDelay: 1000, // 1s, 2s, 4s, … doubling
maxDelay: 30_000, // backoff ceiling
},
},
Inside a handler, exactly three kinds of outcome exist:
| You throw… | The store… |
|---|---|
ConflictError({ remote? }) |
runs the conflict path: merge, one re-push, then surface. |
FatalPushError(message) |
moves the record to error immediately —
validation and authorization failures should not burn a
retry budget.
|
| anything else | treats it as a transport failure: exponential backoff, parking while offline (an attempt never even starts without a connection), waking early when the connection returns. |
A record whose retries are exhausted lands in the
error state with the failure recorded in its meta —
visible via table.failed() and
status.failed, revivable via
retryFailed(), resolveFailed() or
simply saving newer data over it — errors surface through events
and onError, never through global state.
If a table has no push.create (a read-only
reference table, say), a non-local put() throws
SchemaError up front — telling you to either add
the handler or write with { local: true }. Nothing
silently sits in a queue that can never drain.
Guides
Pull handlers#
pull: {
// Called in a loop while done !== true; checkpoint persists per scope.
fetch: async ({ params, scopeKey, checkpoint, isFirstPage, signal }) => ({
items: page.items,
checkpoint: page.cursor, // handed to the next call / next delta sync
done: !page.nextToken, // default: true
}),
// Point lookup for the conflict path and resolveFailed(); null = gone.
fetchOne: (key, { params, signal }) => api.byId(key),
// Server-side counterpart of query() — enables query(f, { remote: true }).
query: (filter, { params, signal }) => api.search(filter, { signal }),
mode: "auto", // "auto" (default): start(), scope change, reconnect
once: false, // true = sync each scope a single time (static data)
deletedKey: "_deleted", // server tombstone field → local removal
mergePending: true, // default: rebase queued records onto fresh remote data
retry: { maxRetries: 3, retryBaseDelay: 1000, maxDelay: 30_000 },
},
Applying a pulled item follows one rule table:
| Local record | Pulled item | Result |
|---|---|---|
| absent | data | inserted as synced |
synced / local | data | replaced if it differs outside ignoreFields; no event otherwise |
pending / pushing | data | rebased: merge(local, remote) replaces the data, the record stays queued — disable with mergePending: false |
pending / pushing | tombstone | untouched — the queued push decides |
tombstoned (pendingOp: "delete") or error | anything | untouched — resolved by the push path / a human |
synced / local | tombstone | removed locally |
| absent | tombstone | ignored |
ignoreFields (store-level plus table-level) lists
server-managed fields — updatedAt,
owner, a version counter — that should not count as
"the record changed". A re-pull where only those fields moved
emits no change events and rewrites nothing.
When data arrives outside the pull loop — a WebSocket snapshot, an SSR payload, a shared fetch layer — apply it imperatively with the same semantics:
socket.on("issues", (items) => {
void store.table("Issue").applyRemote(items, {
deletedKey: "_deleted", // optional; defaults to pull.deletedKey
// mergePending: false, // optional; defaults to pull.mergePending
});
});
applyRemote() follows the rule table above exactly —
items land as synced, queued records are rebased,
tombstoned and error records stay untouched — but it
does not mark the table as synced and does not
move checkpoints: it is data application, not a sync run. A
table does not even need a pull config to use it.
Failed pulls emit syncError, keep the table
unsynced, and re-run on the next trigger — an explicit
table.sync(), a scope change, or the automatic
reconnect sweep. An explicit sync() call also
rejects with the failure, so imperative flows can react.
Guides
Conflicts#
A conflict exists when the server rejects a mutation because
someone changed the record first. The transport signals it by
throwing ConflictError — with the current remote
state attached when the response already carries it, without it
otherwise (the store then calls pull.fetchOne).
From there the path is automatic:
-
merge(local, remote, { params })— the table's merge function, called with copies. The default merge keeps local data field-by-field on top of the remote record, except theignoreFields, which always come from the remote side — list your version field there and the retried push passes optimistic concurrency for free. (The same function also powers the pull-time rebase of queued records, so write it order-independent.) -
The merged record is persisted (a
"merge"-origin change event) and pushed once more in the same run — as anupdateeven if the original op was acreatethat turned out to already exist. -
A second conflict in a row stops the loop: the record moves to
error, aconflict { resolution: "surfaced" }event fires, and the record waits for a human (or your code):
const stuck = await issues.failed();
// [{ key, op, item, error }]
await issues.resolveFailed(stuck[0].key, (local, remote) => {
if (!remote) return "discard-local"; // gone on the server — drop ours
if (remote.status === "RESOLVED") return "keep-local"; // re-push ours as-is
return { ...remote, note: local.note }; // or hand-merge and push that
});
Special cases the store handles for you:
- Delete vs. delete — the remote is already gone: counted as success, the tombstone is removed.
- Delete vs. update — the merge refreshes server-managed fields and the delete retries once with them.
-
Update vs. delete — the record vanished
server-side: re-created through
push.createwhen the table has one, surfaced otherwise. -
Create vs. exists — escalated to
updatewith the merged data.
Guides
Changing scope#
// The user navigated to another event:
await store.setParams({ eventId: route.eventId });
await store.whenSynced();
store.on("paramsChanged", ({ params, resyncedTables }) => {
// resyncedTables: the tables whose scope key changed
});
For every table whose scope.key changed, in order:
- An in-flight pull of the old scope is aborted.
-
Synced records failing
scope.keep(item, newParams)are evicted in one transaction. Records with unpushed changes (pending,pushing,error) are never evicted — a scope change cannot lose data on its way to the server. - If the eviction removed anything, the old scope's checkpoint and completion mark are dropped too — a delta pull cannot refill holes, so returning to that scope refetches it. Untouched scopes keep their marks: navigating back is instant.
- The new scope pulls, resuming from its own checkpoint if it synced before.
Guides
Migrations#
The configured schemaVersion drives IndexedDB
upgrades. Structure is reconciled declaratively: on a
version bump the store diffs the configured tables and indexes
against what exists on disk and creates or drops object stores
and indexes to match — adding a table or an index is just
"declare it, bump the version". A table added without a
bump fails fast at start() with a
SchemaError telling you exactly that.
Data migrations transform records, keyed by the version they migrate to, running inside the upgrade transaction in ascending order over the crossed versions:
createDataStore({
name: "InspectorDataStore",
schemaVersion: 3, // v2 added byState; v3 renames a status
migrations: {
3: async ({ table }) => {
await table("Issue").updateEach((issue) =>
issue.status === "OPENED" ? { ...issue, status: "OPEN" } : issue,
);
},
},
tables: { /* … */ },
});
updateEach(fn) replaces each record's data with the
returned object, deletes on null, keeps on the same
object; getAll(), put(),
delete() cover the rest. A migration that throws
aborts the whole upgrade — the database stays at the old
version, nothing half-commits. Do not await anything external
inside a migration: the upgrade transaction auto-commits.
Guides
Local-only tables#
Draft: defineTable<DraftNote>({ primaryKey: "id", local: true }),
A local: true table is a typed, transactional,
indexed IndexedDB table with no sync machinery at all: records
live in the local state, deletions are physical,
synced is always true, and combining
local with push / pull /
scope is a configuration error. Perfect for drafts,
device caches and UI state that participates in
transactions with synced tables.
Guides
Binary fields#
Some WebKit builds — old iOS Safari most famously — throw
DataCloneError the moment a Blob is
stored in IndexedDB. Declare where your blobs live and the store
works around it the way localForage does: on affected browsers
the blobs are transparently stored as
ArrayBuffers and turned back into real
Blobs on every read. Your code never sees the
difference.
const store = createDataStore({
binary: { mode: "auto" }, // default; "always" / "never" to force
tables: {
Photo: defineTable<Photo>({
primaryKey: "id",
// Typed dot-paths: autocompleted, must point at Blob values.
blobPaths: ["preview", "attachment.file", "frames"], // frames: Blob[]
push: { /* handlers receive real Blobs */ },
}),
},
});
mode: "auto" runs a feature probe at
start(): a tiny Blob is written to a
service store and read back. Both failure modes — the loud
DataCloneError and environments that "store" the
blob but hand back garbage — turn encoding on. The probe costs
about a millisecond, and reads tolerate both stored forms
regardless of the mode, so a browser update that fixes the bug
needs no migration.
The contract, everywhere:
-
get/getAll/query/ events /pending()/failed()— always return realBlobs; -
pushhandlers receive realBlobs and may return them in the echo; -
mergesees realBlobs but must treat blob fields as opaque: pick the local or the remote value whole — fabricating new binary data inside merge throws aSchemaError; -
blob fields are compared by
(MIME type, size)for change detection and cannot be filtered on; -
writes with blobs work through
put/update/applyRemote/ pulled pages — encoding always happens before the transaction opens.
Encoding is asynchronous, and nothing may be awaited inside an
open IndexedDB transaction — so a live Blob in
tx.put() / tx.update() throws a
SchemaError on every platform (never
only in production on an iPhone). Pre-encode instead:
const encoded = await photos.encodeBlobs({ id, name, preview: file });
await store.transaction(["Photo", "Event"], async (tx) => {
await tx.table("Photo").put(encoded); // ok — already encoded
await tx.table("Event").update(eventId, { photoCount: n + 1 });
});
Guides
Testing#
The store runs unmodified on
fake-indexeddb.
Two pieces make tests deterministic: reset the IndexedDB
universe between tests, and alias
@dmytromykhailiuk/network-connection to a
controllable double — through the bundler alias, so
retry-request sees the same state.
// vitest.config.ts
export default defineConfig({
resolve: { alias: { "@dmytromykhailiuk/network-connection": "./tests/network-mock.ts" } },
test: {
environment: "jsdom",
setupFiles: ["tests/setup.ts"],
server: { deps: { inline: ["@dmytromykhailiuk/retry-request"] } },
},
});
// tests/setup.ts
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
beforeEach(() => {
indexedDB = new IDBFactory();
});
This package's own suite — 160+ tests across CRUD, transactions, the push pipeline, conflicts, pulls, scope changes, outbox persistence across "reloads" and concurrency stress — is written exactly this way and doubles as a cookbook.
Reference
Required vs. optional#
Formally almost everything is optional: requirements are enforced
at the moment of the operation that needs them, with a
SchemaError naming exactly what to add — never a
mutation silently stuck in a queue nothing can drain. The one
compile-time exception: a declared pull block must
contain fetch.
Store config#
| Field | Required? | Without it / default |
|---|---|---|
name | always | — |
schemaVersion | always (positive integer) | — |
tables | always (at least one) | — |
initialParams | no | {}; start({ params }) overrides |
migrations | no | structure still reconciles on a version bump; only data transforms need entries |
ignoreFields | no | empty; merged into every table's list |
binary | no | { mode: "auto" } — probe-driven Blob encoding |
onError | no | failures go to logger.error |
logger | no | console |
Table config#
| Field | Required? | Without it / default |
|---|---|---|
primaryKey | always (a non-empty-string field) | — |
indexes | no | no secondary indexes; names must not start with _, unique unsupported |
local | no | false; true forbids push / pull / scope |
ignoreFields | no | store-level list only |
push | per handler — see below | read-only table: writes need { local: true }, deletions — evict() |
pull | no | push-only table; data in — only via applyRemote() |
scope | no | one unnamed scope "", always syncable; within the block key is required, keep defaults to "keep everything" |
merge | no | default merge: local wins field-by-field, ignoreFields from remote |
blobPaths | no | no binary handling; see Binary fields |
push.*#
| Handler | Required when | Without it |
|---|---|---|
create |
a non-local put() of a new record; promoting a local record the server never saw |
SchemaError at the write; also used (optionally) to recreate a record whose update hit a server-side delete — surfaced otherwise |
update |
update() / put() of a server-known record; the resurrection case (delete → put) |
SchemaError at the write; also used (optionally) by the create-conflict escalation — a repeat conflict surfaces otherwise |
delete |
delete() of a server-known record |
SchemaError at the call (hint: evict() removes locally without a push) |
retry |
never | infinite transport retries, backoff 1 s → 30 s, parking while offline |
pull.*#
| Field | Required when | Without it / default |
|---|---|---|
fetch |
always, once a pull block exists (compile-time) |
— |
fetchOne |
never — but strongly recommended with server versioning | a ConflictError without remote surfaces immediately; resolveFailed() resolvers receive null; update-vs-delete cannot be told apart |
query |
only for query(f, { remote: true }) |
SchemaError when the flag is used |
deletedKey | no | no server tombstones |
mode | no | "auto": start / scope change / reconnect |
once | no | false |
mergePending | no | true — queued records are rebased |
retry | no | 3 retries, backoff 1 s → 30 s |
Typical configurations#
// Local-only: push / pull / scope are FORBIDDEN (SchemaError at creation).
Draft: defineTable<Draft>({ primaryKey: "id", local: true }),
// Read-only reference data:
Site: defineTable<Site>({ primaryKey: "id",
pull: { fetch, once: true } }),
// Write-only, immutable after creation:
Vehicle: defineTable<Vehicle>({ primaryKey: "id",
push: { create } }), // update()/delete() throw — by design
// Full CRUD sync — the recommended minimum with server versioning:
Issue: defineTable<Issue, P>({ primaryKey: "id",
push: { create, update, delete },
pull: { fetch, fetchOne }, // fetchOne: conflicts resolve instead of surfacing
merge, // optional; default is LWW + ignoreFields
scope, // optional; without it — one unnamed scope
}),
Reference
The store#
Opens the database (running migrations), restores the outbox,
kicks off auto pulls. Fails fast without
NetworkConnection.init(). Idempotent.
Aborts in-flight pushes and pulls, stops dispatching. Reads
and writes keep working — mutations accumulate in the
persistent outbox and drain on the next
start().
stop() plus closing the database — later calls reject with StoreNotStartedError.
The per-table facade, fully typed from the defineTable declaration.
Atomic multi-table writes — see Transactions.
Merges params, re-scopes tables — see Changing scope. store.params reads them back.
Pulls every auto table; force ignores completion marks.
Nudges the outbox — re-dispatches everything pending.
Wipes every table, the outbox and all checkpoints in one transaction.
{ started, syncing, synced, outboxSize, failed } —
live counters, cheap to read.
Promise forms of the corresponding conditions.
Store-wide events — the full list is in Subscriptions.
Reference
TableStore#
Snapshot reads. Tombstoned records are hidden;
{ includePendingDeleted: true } shows them.
Local by default; remote: true fetches through
pull.query and persists before answering — see
Queries.
The record's bookkeeping: state,
pendingOp, rev,
serverKnown, scopeKey,
error, timestamps.
The table's slice of the outbox (FIFO order) and its
error-state records.
See Reading & writing. All accept
{ local: true } (for delete use
evict).
Removes the record locally without telling the server. Idempotent.
Pre-encodes live Blobs into storage form — the one shape allowed inside transactions. See Binary fields.
Local wipe of the table, its outbox entries and its sync marks.
Imperatively applies server-fetched items with full pull
semantics — see Pull handlers. Does not
mark the table synced; rejects on local: true
tables.
Per-table counterparts of the store-level members.
Puts one (or every) error-state record back in the queue.
Decides a surfaced conflict:
"keep-local", "discard-local", or a
hand-merged record to push — see
Conflicts.
Change events of this table — see Subscriptions.
Reference
Errors#
Every failure is a DataStoreError with a stable
code — branch on instanceof or on the
code, never on message text.
| Class | Code | Thrown when |
|---|---|---|
ConflictError | CONFLICT |
by you, in a push handler, when the server rejected on a version mismatch; remote optionally carries the server state. |
FatalPushError | FATAL_PUSH |
by you, when retrying is pointless — straight to the error state. |
RecordNotFoundError | RECORD_NOT_FOUND |
update() / delete() of a key the table does not hold. |
TransactionInactiveError | TRANSACTION_INACTIVE |
the transaction callback awaited the outside world and IndexedDB auto-committed. |
StoreNotStartedError | STORE_NOT_STARTED |
an operation before start() / after close(), or start() without NetworkConnection.init(). |
SchemaError | SCHEMA |
invalid configuration, filter, or a table added without a schemaVersion bump. |
Reference
Design FAQ#
Why are unique indexes not supported?
A unique index turns every put into a constraint
that can abort a sync transaction halfway through a pulled page.
Uniqueness belongs to the primary key; everything else is a
query.
Why does a pull rebase pending records instead of overwriting them?
Overwriting would silently drop the user's unpushed edit;
skipping (available via
mergePending: false) leaves the queued push built on
a stale base — on a last-write-wins backend it would then blindly
erase other clients' changes. The rebase runs the same
merge the conflict path uses: local fields win,
server-managed fields (ignoreFields) come from the
remote, and the record stays queued. Tombstones and
error-state records are never rebased — the first is
decided by the delete push, the second by a human.
Can I run several stores?
Yes — every createDataStore() is fully isolated:
its own database, locks, queue and events. Use different
names.
What happens on a second tab?
IndexedDB itself is shared and every write is transactional, so data never corrupts. But each tab runs its own outbox dispatcher — for now run the store in one tab (or a SharedWorker / leader-election layer on top). Cross-tab leadership is on the roadmap.
Reference
Exports#
createDataStore · defineTable ·
compileFilter · matchesFilter ·
validateFilter · toPredicate ·
DataStoreError · ConflictError ·
FatalPushError · RecordNotFoundError ·
TransactionInactiveError ·
StoreNotStartedError · SchemaError
Types: DataStore · DataStoreConfig ·
DataStoreEvents · DataStoreStatus ·
TableConfig · TableDefinition ·
TableStore · StoreTransaction ·
TransactionTable · PushConfig ·
PushContext · PullConfig ·
PullContext · PullResult ·
ScopeConfig · Filter ·
FieldCondition · ChangeEvent ·
RecordMeta · RecordState ·
PendingOp · MigrationContext ·
QueryOptions · FailedEntry ·
PendingEntry and more.