react-native-outbox-mutation-queue 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/queue.ts ADDED
@@ -0,0 +1,423 @@
1
+ import { computeBackoff, DEFAULT_RETRY } from './backoff';
2
+ import { createMemoryStorage } from './storage/memory';
3
+ import type { StorageAdapter } from './storage/types';
4
+ import { createId } from './utils/id';
5
+ import type {
6
+ DedupeStrategy,
7
+ FailureKind,
8
+ QueueEventName,
9
+ QueueEvents,
10
+ RetryPolicy,
11
+ Task,
12
+ Unsubscribe,
13
+ } from './types';
14
+
15
+ export interface EnqueueOptions {
16
+ /** Tasks sharing this key collapse per `dedupeStrategy`. */
17
+ dedupeKey?: string;
18
+ /** Overrides the queue-level strategy for this task only. */
19
+ dedupeStrategy?: DedupeStrategy;
20
+ }
21
+
22
+ export interface QueueConfig<P = unknown> {
23
+ /**
24
+ * Performs the actual side effect — usually an API call. Resolving marks
25
+ * the task done; throwing schedules a retry.
26
+ */
27
+ execute: (task: Task<P>) => Promise<void>;
28
+
29
+ /** Where tasks are persisted. Defaults to in-memory (non-durable). */
30
+ storage?: StorageAdapter;
31
+
32
+ /** Key under which the task list is stored. */
33
+ storageKey?: string;
34
+
35
+ retry?: Partial<RetryPolicy>;
36
+
37
+ /** Default collapsing behaviour for tasks carrying a `dedupeKey`. */
38
+ dedupeStrategy?: DedupeStrategy;
39
+
40
+ /** How many tasks may run at once. Default 1 (strict ordering). */
41
+ concurrency?: number;
42
+
43
+ /**
44
+ * Classifies a thrown error. Returning `permanent` stops retrying
45
+ * immediately — use it for 4xx responses that will never succeed.
46
+ */
47
+ classifyError?: (error: unknown, task: Task<P>) => FailureKind;
48
+
49
+ /**
50
+ * Called when a task exhausts its retries or fails permanently. This is
51
+ * the conflict hook: reconcile server state, surface a prompt, or drop it.
52
+ */
53
+ onDiscard?: (task: Task<P>, error: unknown) => void | Promise<void>;
54
+
55
+ /** Start processing as soon as the queue is constructed. Default true. */
56
+ autoStart?: boolean;
57
+
58
+ /**
59
+ * Suppress the one-time development warning emitted the first time a task
60
+ * is collapsed by `dedupeKey`. Set this once you have confirmed the
61
+ * collapsing is intended.
62
+ */
63
+ silenceDedupeWarning?: boolean;
64
+ }
65
+
66
+ type Listeners = {
67
+ [K in QueueEventName]: Set<(...args: never[]) => void>;
68
+ };
69
+
70
+ export class OfflineQueue<P = unknown> {
71
+ private tasks: Task<P>[] = [];
72
+ private readonly storage: StorageAdapter;
73
+ private readonly storageKey: string;
74
+ private readonly retry: RetryPolicy;
75
+ private readonly dedupeStrategy: DedupeStrategy;
76
+ private readonly concurrency: number;
77
+ private readonly config: QueueConfig<P>;
78
+
79
+ private readonly silenceDedupeWarning: boolean;
80
+ private warnedAboutDedupe = false;
81
+
82
+ private online = true;
83
+ private running = false;
84
+ private draining = false;
85
+ private inFlight = 0;
86
+ private timer: ReturnType<typeof setTimeout> | null = null;
87
+ private hydrated: Promise<void>;
88
+
89
+ private listeners: Listeners = {
90
+ enqueued: new Set(),
91
+ deduped: new Set(),
92
+ started: new Set(),
93
+ succeeded: new Set(),
94
+ failed: new Set(),
95
+ discarded: new Set(),
96
+ drained: new Set(),
97
+ changed: new Set(),
98
+ };
99
+
100
+ constructor(config: QueueConfig<P>) {
101
+ this.config = config;
102
+ this.storage = config.storage ?? createMemoryStorage();
103
+ this.storageKey = config.storageKey ?? 'rn-offline-queue/v1';
104
+ this.retry = { ...DEFAULT_RETRY, ...config.retry };
105
+ this.dedupeStrategy = config.dedupeStrategy ?? 'replace';
106
+ this.concurrency = Math.max(1, config.concurrency ?? 1);
107
+ this.silenceDedupeWarning = config.silenceDedupeWarning ?? false;
108
+ this.running = config.autoStart ?? true;
109
+
110
+ this.hydrated = this.hydrate();
111
+ }
112
+
113
+ // ---------------------------------------------------------------- lifecycle
114
+
115
+ /** Resolves once persisted tasks have been loaded from storage. */
116
+ ready(): Promise<void> {
117
+ return this.hydrated;
118
+ }
119
+
120
+ /** Resume processing. */
121
+ start(): void {
122
+ if (this.running) return;
123
+ this.running = true;
124
+ void this.drain();
125
+ }
126
+
127
+ /** Pause processing. In-flight tasks are allowed to finish. */
128
+ pause(): void {
129
+ this.running = false;
130
+ this.clearTimer();
131
+ }
132
+
133
+ /**
134
+ * Report connectivity. Wire this to NetInfo — the queue holds tasks while
135
+ * offline rather than burning retry attempts against a dead network.
136
+ */
137
+ setOnline(online: boolean): void {
138
+ const wasOffline = !this.online;
139
+ this.online = online;
140
+ if (online && wasOffline) void this.drain();
141
+ }
142
+
143
+ isOnline(): boolean {
144
+ return this.online;
145
+ }
146
+
147
+ // ------------------------------------------------------------------- public
148
+
149
+ /** Add a mutation to the queue. Safe to call while offline. */
150
+ async enqueue(
151
+ type: string,
152
+ payload: P,
153
+ options: EnqueueOptions = {},
154
+ ): Promise<Task<P>> {
155
+ await this.hydrated;
156
+
157
+ const now = Date.now();
158
+ const task: Task<P> = {
159
+ id: createId(),
160
+ type,
161
+ payload,
162
+ createdAt: now,
163
+ updatedAt: now,
164
+ attempts: 0,
165
+ status: 'pending',
166
+ dedupeKey: options.dedupeKey,
167
+ };
168
+
169
+ const strategy = options.dedupeStrategy ?? this.dedupeStrategy;
170
+
171
+ if (task.dedupeKey && strategy !== 'keep') {
172
+ // Only collapse against tasks not yet in flight — replacing a running
173
+ // task would leave its side effect half-applied.
174
+ const existingIndex = this.tasks.findIndex(
175
+ (t) => t.dedupeKey === task.dedupeKey && t.status === 'pending',
176
+ );
177
+
178
+ if (existingIndex !== -1) {
179
+ const existing = this.tasks[existingIndex];
180
+
181
+ if (strategy === 'drop') {
182
+ this.noteDedupe();
183
+ this.emit('deduped', existing, task, strategy);
184
+ return existing;
185
+ }
186
+
187
+ // 'replace' — keep queue position, take the newer payload.
188
+ const merged: Task<P> = {
189
+ ...existing,
190
+ payload: task.payload,
191
+ type: task.type,
192
+ updatedAt: now,
193
+ };
194
+ this.tasks[existingIndex] = merged;
195
+ await this.persist();
196
+ this.noteDedupe();
197
+ // `existing` carried the payload that is now gone.
198
+ this.emit('deduped', merged, existing, strategy);
199
+ this.emit('enqueued', merged);
200
+ void this.drain();
201
+ return merged;
202
+ }
203
+ }
204
+
205
+ this.tasks.push(task);
206
+ await this.persist();
207
+ this.emit('enqueued', task);
208
+ void this.drain();
209
+ return task;
210
+ }
211
+
212
+ /** Snapshot of queued tasks, in execution order. */
213
+ list(): Task<P>[] {
214
+ return [...this.tasks];
215
+ }
216
+
217
+ size(): number {
218
+ return this.tasks.length;
219
+ }
220
+
221
+ /** Remove a single task without executing it. */
222
+ async remove(id: string): Promise<boolean> {
223
+ const before = this.tasks.length;
224
+ this.tasks = this.tasks.filter((t) => t.id !== id);
225
+ if (this.tasks.length === before) return false;
226
+ await this.persist();
227
+ return true;
228
+ }
229
+
230
+ /** Drop every queued task. */
231
+ async clear(): Promise<void> {
232
+ this.tasks = [];
233
+ await this.persist();
234
+ }
235
+
236
+ on<K extends QueueEventName>(
237
+ event: K,
238
+ handler: QueueEvents<P>[K],
239
+ ): Unsubscribe {
240
+ const set = this.listeners[event] as Set<unknown>;
241
+ set.add(handler);
242
+ return () => {
243
+ set.delete(handler);
244
+ };
245
+ }
246
+
247
+ // ------------------------------------------------------------------ internal
248
+
249
+ /**
250
+ * Deduplication discards work by design, but a silent discard is how the
251
+ * "my chat messages disappeared" bug happens. Warn once per queue in dev so
252
+ * the behaviour is discovered during development rather than in production.
253
+ */
254
+ private noteDedupe(): void {
255
+ if (this.warnedAboutDedupe || this.silenceDedupeWarning) return;
256
+ this.warnedAboutDedupe = true;
257
+
258
+ const dev = (globalThis as { __DEV__?: boolean }).__DEV__;
259
+ if (dev === false) return;
260
+
261
+ // eslint-disable-next-line no-console
262
+ console.warn(
263
+ '[outbox] A queued task was collapsed because it shared a dedupeKey ' +
264
+ 'with another. This is intentional for drafts and form saves, but it ' +
265
+ 'DISCARDS the other payload — do not use dedupeKey for items that ' +
266
+ 'must each be delivered, such as chat messages. Listen to the ' +
267
+ '"deduped" event to observe this, or pass silenceDedupeWarning: true.',
268
+ );
269
+ }
270
+
271
+ private async hydrate(): Promise<void> {
272
+ try {
273
+ const raw = await this.storage.getItem(this.storageKey);
274
+ if (raw) {
275
+ const parsed = JSON.parse(raw) as Task<P>[];
276
+ // Anything left mid-flight from a previous session is retried; the
277
+ // process died before we learned the outcome.
278
+ this.tasks = parsed.map((t) =>
279
+ t.status === 'running' ? { ...t, status: 'pending' } : t,
280
+ );
281
+ }
282
+ } catch {
283
+ // Corrupt payload should not brick the app — start clean.
284
+ this.tasks = [];
285
+ }
286
+ this.emit('changed', this.list());
287
+ void this.drain();
288
+ }
289
+
290
+ private async persist(): Promise<void> {
291
+ this.emit('changed', this.list());
292
+ try {
293
+ await this.storage.setItem(this.storageKey, JSON.stringify(this.tasks));
294
+ } catch {
295
+ // Storage failure must not lose the in-memory queue.
296
+ }
297
+ }
298
+
299
+ private nextRunnable(now: number): Task<P> | undefined {
300
+ return this.tasks.find(
301
+ (t) =>
302
+ t.status === 'pending' && (!t.nextAttemptAt || t.nextAttemptAt <= now),
303
+ );
304
+ }
305
+
306
+ private async drain(): Promise<void> {
307
+ if (this.draining) return;
308
+ if (!this.running || !this.online) return;
309
+
310
+ this.draining = true;
311
+ try {
312
+ while (this.running && this.online && this.inFlight < this.concurrency) {
313
+ const now = Date.now();
314
+ const task = this.nextRunnable(now);
315
+ if (!task) break;
316
+ void this.run(task);
317
+ }
318
+ } finally {
319
+ this.draining = false;
320
+ }
321
+
322
+ this.scheduleNext();
323
+ }
324
+
325
+ /** Wake up when the earliest backed-off task becomes eligible. */
326
+ private scheduleNext(): void {
327
+ this.clearTimer();
328
+ if (!this.running || !this.online) return;
329
+
330
+ const waiting = this.tasks
331
+ .filter((t) => t.status === 'pending' && t.nextAttemptAt)
332
+ .map((t) => t.nextAttemptAt as number);
333
+
334
+ if (waiting.length === 0) {
335
+ if (this.tasks.length === 0 && this.inFlight === 0) this.emit('drained');
336
+ return;
337
+ }
338
+
339
+ const delay = Math.max(0, Math.min(...waiting) - Date.now());
340
+ this.timer = setTimeout(() => {
341
+ this.timer = null;
342
+ void this.drain();
343
+ }, delay);
344
+ }
345
+
346
+ private clearTimer(): void {
347
+ if (this.timer) {
348
+ clearTimeout(this.timer);
349
+ this.timer = null;
350
+ }
351
+ }
352
+
353
+ private async run(task: Task<P>): Promise<void> {
354
+ task.status = 'running';
355
+ task.attempts += 1;
356
+ task.updatedAt = Date.now();
357
+ this.inFlight += 1;
358
+ this.emit('started', task);
359
+ await this.persist();
360
+
361
+ try {
362
+ await this.config.execute(task);
363
+ this.tasks = this.tasks.filter((t) => t.id !== task.id);
364
+ await this.persist();
365
+ this.emit('succeeded', task);
366
+ } catch (error) {
367
+ await this.handleFailure(task, error);
368
+ } finally {
369
+ this.inFlight -= 1;
370
+ void this.drain();
371
+ }
372
+ }
373
+
374
+ private async handleFailure(task: Task<P>, error: unknown): Promise<void> {
375
+ const kind: FailureKind =
376
+ this.config.classifyError?.(error, task) ?? 'transient';
377
+
378
+ const exhausted = task.attempts >= this.retry.maxAttempts;
379
+ const willRetry = kind === 'transient' && !exhausted;
380
+
381
+ task.lastError = error instanceof Error ? error.message : String(error);
382
+ task.updatedAt = Date.now();
383
+
384
+ if (willRetry) {
385
+ task.status = 'pending';
386
+ task.nextAttemptAt = Date.now() + computeBackoff(task.attempts, this.retry);
387
+ await this.persist();
388
+ this.emit('failed', task, error, true);
389
+ return;
390
+ }
391
+
392
+ task.status = 'discarded';
393
+ this.tasks = this.tasks.filter((t) => t.id !== task.id);
394
+ await this.persist();
395
+ this.emit('failed', task, error, false);
396
+ this.emit('discarded', task, error);
397
+
398
+ try {
399
+ await this.config.onDiscard?.(task, error);
400
+ } catch {
401
+ // A throwing conflict handler must not stall the queue.
402
+ }
403
+ }
404
+
405
+ private emit<K extends QueueEventName>(
406
+ event: K,
407
+ ...args: Parameters<QueueEvents<P>[K]>
408
+ ): void {
409
+ for (const handler of this.listeners[event]) {
410
+ try {
411
+ (handler as (...a: unknown[]) => void)(...(args as unknown[]));
412
+ } catch {
413
+ // Listener errors are contained.
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ export function createQueue<P = unknown>(
420
+ config: QueueConfig<P>,
421
+ ): OfflineQueue<P> {
422
+ return new OfflineQueue<P>(config);
423
+ }
package/src/react.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { OfflineQueue } from './queue';
3
+ import type { Task } from './types';
4
+
5
+ export interface QueueState<P = unknown> {
6
+ tasks: Task<P>[];
7
+ pending: number;
8
+ isOnline: boolean;
9
+ }
10
+
11
+ /**
12
+ * Subscribe a component to queue contents.
13
+ *
14
+ * ```tsx
15
+ * const { pending, isOnline } = useOfflineQueue(queue);
16
+ * if (pending > 0) return <Text>{pending} change(s) waiting to sync</Text>;
17
+ * ```
18
+ */
19
+ export function useOfflineQueue<P = unknown>(
20
+ queue: OfflineQueue<P>,
21
+ ): QueueState<P> {
22
+ const [tasks, setTasks] = useState<Task<P>[]>(() => queue.list());
23
+ const [isOnline, setIsOnline] = useState<boolean>(() => queue.isOnline());
24
+
25
+ useEffect(() => {
26
+ setTasks(queue.list());
27
+ setIsOnline(queue.isOnline());
28
+
29
+ const unsubscribe = queue.on('changed', (next) => {
30
+ setTasks(next);
31
+ setIsOnline(queue.isOnline());
32
+ });
33
+
34
+ return unsubscribe;
35
+ }, [queue]);
36
+
37
+ return {
38
+ tasks,
39
+ pending: tasks.filter((t) => t.status !== 'discarded').length,
40
+ isOnline,
41
+ };
42
+ }
@@ -0,0 +1,20 @@
1
+ import type { StorageAdapter } from './types';
2
+
3
+ /**
4
+ * In-memory adapter. Used as the default so the queue works without
5
+ * configuration, and useful in tests. Nothing survives a restart.
6
+ */
7
+ export function createMemoryStorage(): StorageAdapter {
8
+ const map = new Map<string, string>();
9
+ return {
10
+ async getItem(key) {
11
+ return map.has(key) ? (map.get(key) as string) : null;
12
+ },
13
+ async setItem(key, value) {
14
+ map.set(key, value);
15
+ },
16
+ async removeItem(key) {
17
+ map.delete(key);
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Minimal persistence contract.
3
+ *
4
+ * Deliberately shaped like AsyncStorage so `@react-native-async-storage/
5
+ * async-storage` can be passed straight in, while MMKV, SQLite or a test
6
+ * double can be adapted in a few lines.
7
+ */
8
+ export interface StorageAdapter {
9
+ getItem(key: string): Promise<string | null>;
10
+ setItem(key: string, value: string): Promise<void>;
11
+ removeItem(key: string): Promise<void>;
12
+ }
package/src/types.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Core types for the offline mutation queue.
3
+ */
4
+
5
+ export type TaskStatus = 'pending' | 'running' | 'failed' | 'discarded';
6
+
7
+ export interface Task<P = unknown> {
8
+ /** Stable unique id, generated on enqueue. */
9
+ id: string;
10
+ /** Caller-defined operation name, e.g. `updateProfile`. */
11
+ type: string;
12
+ payload: P;
13
+ createdAt: number;
14
+ updatedAt: number;
15
+ /** Number of execution attempts made so far. */
16
+ attempts: number;
17
+ status: TaskStatus;
18
+ /** Tasks sharing a key collapse according to the dedupe strategy. */
19
+ dedupeKey?: string;
20
+ /** Message from the most recent failure, kept for debugging. */
21
+ lastError?: string;
22
+ /** Epoch ms before which the task must not be retried. */
23
+ nextAttemptAt?: number;
24
+ }
25
+
26
+ /**
27
+ * What to do when a newly enqueued task shares a `dedupeKey` with one
28
+ * already waiting.
29
+ *
30
+ * - `replace` keeps the newest payload (last write wins) — the right default
31
+ * for "save this form" style mutations.
32
+ * - `drop` keeps the task already queued and ignores the new one.
33
+ * - `keep` disables collapsing and queues both.
34
+ */
35
+ export type DedupeStrategy = 'replace' | 'drop' | 'keep';
36
+
37
+ export interface RetryPolicy {
38
+ /** Attempts before a task is handed to `onDiscard`. Default 5. */
39
+ maxAttempts: number;
40
+ /** First backoff delay in ms. Default 1000. */
41
+ baseDelayMs: number;
42
+ /** Upper bound for a single backoff delay in ms. Default 60_000. */
43
+ maxDelayMs: number;
44
+ /** Randomisation applied to each delay, 0–1. Default 0.3. */
45
+ jitter: number;
46
+ }
47
+
48
+ /**
49
+ * Thrown-error classification. Returning `permanent` skips remaining
50
+ * retries — use it for 4xx responses that will never succeed.
51
+ */
52
+ export type FailureKind = 'transient' | 'permanent';
53
+
54
+ export interface QueueEvents<P = unknown> {
55
+ enqueued: (task: Task<P>) => void;
56
+ /**
57
+ * A task was collapsed into another because they shared a `dedupeKey`.
58
+ * `kept` remains queued; `dropped` was discarded.
59
+ *
60
+ * Deduplication is intentional, but it does throw work away. Listen here to
61
+ * log or reconcile — and to catch the classic mistake of giving a dedupeKey
62
+ * to things that must each be delivered, such as chat messages.
63
+ */
64
+ deduped: (kept: Task<P>, dropped: Task<P>, strategy: DedupeStrategy) => void;
65
+ started: (task: Task<P>) => void;
66
+ succeeded: (task: Task<P>) => void;
67
+ failed: (task: Task<P>, error: unknown, willRetry: boolean) => void;
68
+ discarded: (task: Task<P>, error: unknown) => void;
69
+ drained: () => void;
70
+ /** Fired whenever the persisted task list changes. */
71
+ changed: (tasks: Task<P>[]) => void;
72
+ }
73
+
74
+ export type QueueEventName = keyof QueueEvents;
75
+
76
+ export type Unsubscribe = () => void;
@@ -0,0 +1,15 @@
1
+ let counter = 0;
2
+
3
+ /**
4
+ * Collision-resistant id without pulling in a uuid dependency.
5
+ * Time-prefixed so ids sort roughly in creation order.
6
+ */
7
+ export function createId(): string {
8
+ counter = (counter + 1) % 0xffff;
9
+ const time = Date.now().toString(36);
10
+ const seq = counter.toString(36).padStart(3, '0');
11
+ const rand = Math.floor(Math.random() * 0xffffff)
12
+ .toString(36)
13
+ .padStart(4, '0');
14
+ return `${time}-${seq}-${rand}`;
15
+ }