3.1 KiB
sync-queue/sync-queue.ts
(no submodules)
Purpose
Offline-first request queue that persists requests while the device is offline
and replays them when connectivity returns. The queue is backend-agnostic: it
delegates persistence to any SyncQueueStore (memory, localStorage, or
IndexedDB), validates every entry through SyncQueueEntrySchema at the
enqueue boundary, runs an exponential-backoff retry loop, and emits OTel
counters and histograms covering enqueue / processed / failed counts plus
flush duration. Migration from older schema versions happens automatically on
every load.
Features
- Pluggable store (
MemoryStore,LocalStorageStore,IndexedDbStore). - Zod boundary validation on every enqueue; failures raise
PwaError(SYNC_QUEUE_VALIDATION). - Capacity enforcement via
PwaError(SYNC_QUEUE_FULL)whenmaxSizeis hit. - Auto-migration of older schema versions on every load (idempotent).
- Single-flight
process()— concurrent invocations short-circuit to0. - Exponential-backoff
retryDelayFor(retryCount)accessor. - Injectable
ClockandIdGeneratorfor deterministic tests. - OTel emission:
pwa_sync_enqueued_total,pwa_sync_processed_total,pwa_sync_failed_total,pwa_sync_flush_ms.
Usage: Basic
import { SyncQueue, IndexedDbStore } from "@suaveplan/pwa";
const queue = new SyncQueue({ store: new IndexedDbStore() });
await queue.enqueue("/api/v1/orders", "POST", {
body: JSON.stringify({ sku: "abc", qty: 2 }),
headers: { "content-type": "application/json" },
});
await queue.process();
Usage: Advanced
Inject a clock + id generator + custom processor + failure hook for tests:
import { SyncQueue, MemoryStore, type Clock } from "@suaveplan/pwa";
const clock: Clock = { now: () => 1_700_000_000_000 };
let n = 0;
const queue = new SyncQueue({
store: new MemoryStore(),
clock,
idGenerator: () => `sq_${++n}`,
maxRetries: 2,
retryDelay: 100,
retryBackoffFactor: 3,
onProcess: async (entry) => entry.url !== "/fail",
onFailure: (entry) => console.error("permanently failed", entry.id),
});
API Reference
new SyncQueue(options: SyncQueueOptions).enqueue(url, method, options?): Promise<SyncQueueEntry>.remove(id): Promise<boolean>.getAll(): Promise<SyncQueueEntry[]>.size(): Promise<number>.clear(): Promise<void>.process(): Promise<number>.retryDelayFor(retryCount): number.isProcessing: boolean(readonly).
Implementation Notes
The queue serialises the entire snapshot on every mutation rather than
performing per-entry CRUD against the underlying store. This keeps the
SyncQueueStore contract minimal (load / save) and lets every store
implementation guarantee atomicity simply by wrapping save() in its native
transaction primitive. The process() body avoids Promise.all so failures
in one entry cannot cancel sibling work; instead each entry is awaited
sequentially and its outcome recorded. The histogram records the entire flush
duration including loads and saves so the observed value matches user-felt
latency rather than CPU-only time.