qumra-pos-storage
v0.1.2
Published
Offline-first storage core for POS/apps: SQLite behind a swappable adapter port, forward-only migrations, an idempotent outbox, and a full sync engine (push/pull/backoff). Runs on React Native, Web, Desktop (Electron) and Node.
Downloads
483
Maintainers
Readme
qumra-pos-storage
Offline-first storage core for POS and local-first apps. All the logic lives in the core; the SQLite engine is a swappable adapter, so the same schema, migrations, repositories, outbox and sync engine run on React Native, Web, Desktop (Electron) and Node — you only pick the adapter per platform.
Zero dependency on any other @qumra package. Only runtime dep is uuidv7.
Install
npm i qumra-pos-storage
# or
yarn add qumra-pos-storage
# or
pnpm add qumra-pos-storageThen add the driver for your platform (only the one you use):
| Platform | Driver (install yourself) | Import |
| --- | --- | --- |
| Node / Electron (main) | better-sqlite3 | qumra-pos-storage/better-sqlite3 |
| React Native | @op-engineering/op-sqlite | qumra-pos-storage/op-sqlite |
| Web | wa-sqlite (or any wasm SQLite) | qumra-pos-storage/wa-sqlite |
better-sqlite3 is an optional peer dependency — install it only for the Node/Electron adapter.
Quick start (Node / Electron)
import { createStorage } from "qumra-pos-storage";
import { BetterSqlite3Adapter } from "qumra-pos-storage/better-sqlite3";
const storage = await createStorage(
BetterSqlite3Adapter.open({ filename: "/path/to/pos.db" }),
);
const shift = await storage.shifts.open({ cashierId, openingCash: 10000 });
await storage.orders.create({
shiftId: shift.id,
cashierId,
items: [{ productId, nameSnapshot: "Coffee", unitPrice: 2500, qty: 2 }],
payments: [{ method: "cash", amount: 5000 }],
});React Native (op-sqlite) & Web (wa-sqlite)
These adapters take a driver you open and inject, so the package never imports the native/wasm libraries directly:
// React Native
import { open } from "@op-engineering/op-sqlite";
import { openOpSqliteAdapter } from "qumra-pos-storage/op-sqlite";
const storage = await createStorage(openOpSqliteAdapter(open({ name: "pos.db" })));
// Web — wrap your wa-sqlite instance in a small { exec(sql, params) } object
import { openWaSqliteAdapter } from "qumra-pos-storage/wa-sqlite";
const storage = await createStorage(openWaSqliteAdapter(myWaSqliteDb));What you get
StorageAdapterport — four async methods (execute,query,transaction,close). Implement it once to support any engine.- Schema + forward-only migrations —
_migrationsbookkeeping, idempotent. - Repositories —
shifts,orders,products,reports,outbox. - Invariants enforced in the core (not the UI): every write enqueues a
pending_operationsoutbox row in the same transaction;orders.createrejects unlesssum(payments) === total; money is integer piasters; IDs are UUIDv7 (chronological); items snapshot name + price at sale time. - Sync engine —
SyncEnginedrains the outbox (batched, idempotent, exponential backoff + jitter) and pulls reference data (delta + tombstones). It never talks to your server — you inject aSyncTransport. - Conformance suite —
qumra-pos-storage/conformanceexposes a battery every adapter must pass, so a new adapter is provably correct.
Sync engine — you own the network
The engine handles batching, idempotency, UUIDv7 ordering, backoff and the outbox. It has
zero endpoint code: you inject a SyncTransport (REST, GraphQL — your choice) and you
hand it the auth token. The library takes a token from you and never contacts an auth
server.
import { SyncEngine, SyncNetworkError, type SyncTransport } from "qumra-pos-storage";
const transport: SyncTransport = {
// push the outbox — return one result per op: applied | duplicate | rejected
async pushBatch(ops, ctx) {
const res = await fetch("https://your-api/whatever", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${ctx.token}` },
body: JSON.stringify({ operations: ops }),
});
if (!res.ok) throw new SyncNetworkError(`HTTP ${res.status}`); // retryable → backoff
return res.json(); // { results: [{ idempotency_key, status, error? }] }
},
async pullDelta(resource, since, cursor, ctx) {
// your GET / GraphQL query …
return { changed: [], deleted: [], cursor: "", hasMore: false };
},
};
const engine = new SyncEngine(storage, {
transport,
getAuthToken: () => myToken, // ← you provide the token; null ⇒ sync pauses (not an error)
});
engine.start();
engine.onStatusChange((s) => renderBar(s)); // { online, paused, pendingCount, failedOps, ... }Rules: throw on network / 5xx (retryable, backoff); return a per-op
status: "rejected" for a permanent business rejection (surfaces to the manager, the rest
still syncs). REST vs GraphQL is entirely your call — the engine doesn't care.
Testing / adding an adapter
import { describe, it } from "vitest";
import { runAdapterConformance } from "qumra-pos-storage/conformance";
import { BetterSqlite3Adapter } from "qumra-pos-storage/better-sqlite3";
runAdapterConformance("my-adapter", () => BetterSqlite3Adapter.open(), { describe, it });Publishing
Ships dual ESM + CJS + .d.ts (built with tsup). To publish under a different name,
change name in package.json. npm publish builds dist via prepublishOnly.
MIT.
