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

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

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 no createStorage, createAuth or SyncEngine to wire up. If you need the engine without React (Electron main process, Node scripts, tests), import it from qumra-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-offline

Then 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 delete

Filter 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, and serverId gives 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. idMap tells 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 modesonline · 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 null token 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 adapter reference must be stable — build it outside the component or in useMemo. Inline in JSX reopens the database on every render.
  • Money is a plain number at the API boundary. A money field takes 12.5 and stores 1250. Multiplying by 100 yourself is now the silent bug.
  • Renaming a field without renamedFrom drops 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.
  • useDocs returns StoredDoc<T>: your object is doc.data, the id is doc.id.
  • Never persist a server id as your own key. Keep referencing the local id — the library translates on upload. Storing _server_id in a field of your own splits the row in two.
  • No structuredClone, no TextEncoder — 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.