qumra-pos-offline
v0.1.9
Published
Offline-first foundation for POS/apps as React hooks: one <QumraProvider> wires SQLite-backed storage (you declare the collections — no built-in tables; automatic schema diffing, idempotent outbox, push/pull sync engine, local-id ↔ server-id mapping so of
Maintainers
Readme
qumra-pos-offline
Offline-first POS (or any local-first app) as React hooks. Wrap your app in one
<QumraProvider>, then read and write through hooks. SQLite storage, a full push/pull
sync engine, and offline-capable identity all run underneath — you never construct them.
Pure-JS core. Runs on React Native, Web, Desktop (Electron) and Node — you only pick the SQLite adapter for your platform.
The public API is
<QumraProvider>+ six hooks. There is nocreateStorage,createAuthorSyncEngineto wire up. If you need the engine without React (Electron main process, Node scripts, tests), import it fromqumra-pos-offline/core.
You own the schema. The library ships no products/orders/shifts tables. You declare your collections — field names and types, exactly as your API returns them — and the library creates the tables, and on every start diffs your schema against what's actually in SQLite and applies the difference. Add a field, drop one, change its type, rename it: no migration files, no hand-written SQL.
Install
npm i qumra-pos-offlineThen the SQLite driver for your platform (only the one you use):
| Platform | Driver | Adapter import |
| --- | --- | --- |
| Node / Electron | better-sqlite3 | qumra-pos-offline/better-sqlite3 |
| React Native | @op-engineering/op-sqlite | qumra-pos-offline/op-sqlite |
| Web | wa-sqlite | qumra-pos-offline/wa-sqlite |
Runtime deps are only uuidv7, @noble/hashes (argon2id) and @noble/curves (Ed25519)
— all pure-JS, no native build. react and better-sqlite3 are optional peers.
Define your schema
A collection is a table. A field key is the path in your object as your API returns it — a dot means nesting — and the value is its type.
import { defineCollection } from "qumra-pos-offline";
export const products = defineCollection({
name: "products",
fields: {
_id: { type: "string", pk: true }, // exactly one pk
title: { type: "string", index: true },
status: "string",
"pricing.price": "money", // pass/read 12.5 — stored as 1250 (integer cents)
"identification.sku": { type: "string", index: true },
quantity: "number",
variants: "json", // nested arrays/objects stay as they are
},
sync: { resource: "products" }, // ⇒ pulled from your server automatically
});| Type | Stored as | You pass / get |
| --- | --- | --- |
| string · date | TEXT | string |
| number | REAL | number |
| money | INTEGER cents | plain number (12.5) — no float errors, no manual ×100 |
| boolean | INTEGER 0/1 | boolean |
| json | TEXT | any object/array |
"pricing.price" becomes a real, indexable column (pricing__price) and rehydrates as
{ pricing: { price } } on read. Anything your server sends that you didn't declare is
not lost — it's kept and returned in place, so you only declare what you filter, search or
compute on.
Need the Qumra GraphQL schema ready-made? QUMRA_POS_COLLECTIONS (products, collections,
customers, orders, shifts, tax) is exported as plain data — use it, or copy and edit it.
Setup
The only place you touch configuration. adapter + collections get you storage; add
api + vault + activationPublicKey for identity, and transport for sync.
import { QumraProvider } from "qumra-pos-offline";
import { BetterSqlite3Adapter } from "qumra-pos-offline/better-sqlite3";
// Build once, outside the component — its identity is the cache key.
const adapter = BetterSqlite3Adapter.open({ filename: "/path/pos.db" });
<QumraProvider
adapter={adapter}
collections={[products, orders, shifts]}
api={api} // your server calls: login / refresh
vault={vault} // platform secure storage
activationPublicKey={key} // Ed25519, for offline activation
transport={transport} // your server calls: pushBatch / pullDelta
fallback={<Splash />} // shown while the DB opens
>
<App />
</QumraProvider>Children mount only after the database is open and the schema is in sync, so every hook below is guaranteed a ready store. Build failures throw during render for the nearest error boundary.
The Provider wires the two layers together for you: the auth token is injected into the
sync engine, and cashier PINs verify against the synced cashiers table. No glue code.
Changing a field later
Edit the definition and restart. On boot the library reads the actual schema out of SQLite, diffs it, and applies the difference in one transaction:
| You do | It does |
| --- | --- |
| add a field | ALTER TABLE … ADD COLUMN (+ index) — existing rows keep their data |
| drop a field | rebuilds the table, carrying every other column across |
| change a type | rebuilds the table |
| rename a field | add renamedFrom: "oldName" once → RENAME COLUMN, data moves with it |
storage.migration tells you exactly what ran (empty = nothing changed).
Reading and writing
useCollection reads reactively — it re-runs when its options change and after any
write, no manual refetch().
import { useCollection } from "qumra-pos-offline";
const { items, loading, save, update, remove, saving, error } =
useCollection<Product>("products", { search: { path: "title", term: q } });
await save({ title: "Tea", pricing: { price: 12.5 } }); // _id auto-generated (UUIDv7)
await update(id, { quantity: 3 }); // partial patch
await remove(id); // soft delete, syncs as a deleteFilter by any declared field path — where: { status: "active", "identification.sku": "T-1" }.
An undeclared path throws, so no column name can ever reach the SQL from user input.
Write functions both throw and set error — use whichever style you prefer.
For data with no fixed shape at all, useDocs(collection) is a schemaless document store:
any keys, same offline-first writes, outbox row and sync.
const { docs, create, save, remove } = useDocs<Customer>("customers");Your own functions, same shape
Need an operation the hooks don't cover? useStorage() hands you the repositories, so you
can write any function and wrap it in your own hook:
import { useStorage } from "qumra-pos-offline";
function useRefunds() {
const { collection, docs } = useStorage();
const [saving, setSaving] = useState(false);
const refund = useCallback(async (orderId: string, reason: string) => {
setSaving(true);
try {
const order = await collection<Order>("orders")
.update(orderId, { orderStatus: "refunded" });
await docs.create("refunds", { orderId, reason, at: Date.now() });
return order;
} finally {
setSaving(false);
}
}, [collection, docs]);
return { refund, saving };
}Reads re-run after any write that goes through the library — including this one, and
including rows arriving from a pull. refetch() is only for data changed behind the
library's back (raw SQL on the adapter).
Composite reads go through useStorageQuery, which re-runs on any write:
const useShiftOrders = (shiftId: string) =>
useStorageQuery((s) => s.collection<Order>("orders").find({ where: { shiftId } }), [shiftId]);Local ids vs server ids
You create a row offline; it gets a local UUIDv7 and rows created after it reference that id. Then it uploads and your server hands back its own id. Everything still queued behind it now points at an id the server has never heard of.
The library closes that gap. The vocabulary is the one your backend already speaks —
clientOpId, tempId, serverId, batchId. Every op reaching your transport carries the
answer already worked out:
{
clientOpId: "aaaa…", // idempotency key — a bare UUID, stable across retries
opType: "shifts.upsert",
occurredAt: "2026-06-04T07:00:12Z", // when it happened on the device, not when it uploads
payload: { id: "tmp…", data: { … } }, // the row's own id stays LOCAL — this is your tempId
tempId: "tmp…",
serverId: "6a1f…" | null, // the server's _id for this row, if it has one yet
idMap: { "tmp_shift…": "6a1f…" }, // references that were translated inside the payload
}And the batch itself gets a batchId (fresh UUID per send, for audit) in the transport
context. All you return is the server's _id:
// your transport
return {
results: ops.map((op) => ({
clientOpId: op.clientOpId, // echo it back — that's how results are matched
status: "applied",
serverId: created._id, // ⇐ the only thing you have to do
})),
};From there it is handled for you:
- The row's own id is never swapped — you send it as the
tempId, andserverIdgives you the real key separately. No guessing from the shape of a string. - References to other rows are translated in place once their target has a serverId.
idMaptells you which values those are, so you know to put them in the real id field (shiftId) rather than the temp one (shiftTempId). A reference whose target hasn't uploaded yet stays a tempId — send it as such and let the server resolve it inside the batch. - The mapping is stored (
id_mappings) and written onto the row (_server_id). - A row coming back down from a pull under its serverId finds its local row instead of duplicating it.
If your server can't resolve tempId references within a batch, set
sync={{ serverResolvesRefsInBatch: false }}: ops that reference a row still uploading are
then held back one cycle, so a reference is never sent before its target exists. Costs a
round trip; needed only for servers without in-batch resolution.
The local primary key never changes. Your in-app references keep working; translation
happens only at the network boundary. If your server accepts your ids as-is, just omit
serverId and nothing happens.
const { meta } = await collection("orders").getRecord(tempId);
meta.serverId; // "6a1f…" | null (not uploaded yet)
await storage.ids.serverId(tempId); // same, from the map| id | who makes it | shape | what it's for |
| --- | --- | --- | --- |
| clientOpId | the library, per write | UUIDv4 | idempotency — a replayed batch changes nothing |
| tempId | the library, per row (the local pk) | UUIDv7 | linking ops to each other before they exist server-side |
| serverId | your server | whatever it uses (ObjectId…) | the row's real, final id |
| batchId | the library, per send | UUIDv4 | audit only — never dedup on it |
The two ids that cross the wire are v4, deliberately: validators like IsUUID() check
the UUID's version, and default to v4 — a v7 is rejected at the boundary before any
business logic sees it. Row keys stay v7 because their timestamp prefix is what gives
the outbox its upload order for free; the server never sees their shape anyway.
clientOpId is a bare UUID — no prefix, no composite. The operation's context travels
in opType / tempId, not smuggled inside the key.
useAuth
const { mode, status, cashier, canSell, isOnline, login, cashierLogin } = useAuth();
if (!canSell) return <Locked reason={status.lockedReason} />;
await login({ username, password });
await cashierLogin("cashier-1", "1234");Reactive, no polling. Underneath:
- Four modes —
online·offline-degraded(was online, token expired / no network, keeps running on a local session) ·offline-activated(an Ed25519-signed key verified locally, server never contacted) ·unactivated. active → grace → locked— data is never erased; the worst case is that new sales lock. Grace still sells (with a warning); recovery = any successful online contact. Default 30 days + 3 days grace.- Tokens — OAuth vault with single-flight rotating refresh. A
nulltoken means pause, not logout. - Local PIN — argon2id against a synced hash (same code online & offline), with per-cashier lockout.
useSync
const { online, paused, pendingCount, failedOps, push } = useSync();
const { ok, error, synced } = await push(); // a button gets an answer
if (!ok) toast(`No connection — the sale is saved and will upload (${error})`);pendingCount is live: it moves the moment a write lands in the outbox, not after a
push cycle. Rows arriving from a pull re-run your reads on their own too — no refetch().
push() retries with exponential backoff and then returns (maxPushRetries, default
3) instead of looping forever, so the UI can tell the cashier what happened. Giving up
costs nothing: the ops stay pending and the periodic cycle (push and pull) keeps trying.
paused means there's no valid token — a pause, not an error, and nothing is lost.
All hooks
| Hook | Returns |
| --- | --- |
| useCollection(name, opts?) | items · save · update · remove · get · loading · saving · error |
| useStorageQuery(fn, deps?, opts?) | data · loading · error · refetch |
| useDocs(collection) | docs · create · save · remove · get · saving |
| useAuth() | mode · status · cashier · canSell · login · cashierLogin · … |
| useSync() | online · paused · pendingCount (live) · failedOps · push → { ok, error, … } |
| useStorage() | raw: collection(name) · docs · outbox · ids · adapter |
Gotchas
- The
adapterreference must be stable — build it outside the component or inuseMemo. Inline in JSX reopens the database on every render. - Money is a plain number at the API boundary. A
moneyfield takes12.5and stores1250. Multiplying by 100 yourself is now the silent bug. - Renaming a field without
renamedFromdrops the column — the diff sees the old name gone and the new one missing, so the data does not follow. Say where it came from. - A field you never declared still round-trips (it's preserved), but you cannot filter or index by it until you declare it.
useDocsreturnsStoredDoc<T>: your object isdoc.data, the id isdoc.id.- Never persist a server id as your own key. Keep referencing the local id — the
library translates on upload. Storing
_server_idin a field of your own splits the row in two. - No
structuredClone, noTextEncoder— the core runs on Hermes (React Native) with no polyfill.
Without React
qumra-pos-offline/core exposes the same engine directly — createStorage, createAuth,
SyncEngine, every port and type. That's what the Electron main process and the
integration tests use.
import { createStorage, createAuth, SyncEngine } from "qumra-pos-offline/core";Adapter authors: qumra-pos-offline/conformance is the battery every StorageAdapter
must pass.
MIT.
