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/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +345 -0
- package/lib/backoff.d.ts +12 -0
- package/lib/backoff.js +30 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +11 -0
- package/lib/queue.d.ts +100 -0
- package/lib/queue.js +299 -0
- package/lib/react.d.ts +16 -0
- package/lib/react.js +30 -0
- package/lib/storage/memory.d.ts +6 -0
- package/lib/storage/memory.js +21 -0
- package/lib/storage/types.d.ts +12 -0
- package/lib/storage/types.js +2 -0
- package/lib/types.d.ts +68 -0
- package/lib/types.js +5 -0
- package/lib/utils/id.d.ts +5 -0
- package/lib/utils/id.js +17 -0
- package/package.json +68 -0
- package/src/backoff.ts +34 -0
- package/src/index.ts +17 -0
- package/src/queue.ts +423 -0
- package/src/react.ts +42 -0
- package/src/storage/memory.ts +20 -0
- package/src/storage/types.ts +12 -0
- package/src/types.ts +76 -0
- package/src/utils/id.ts +15 -0
package/lib/queue.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OfflineQueue = void 0;
|
|
4
|
+
exports.createQueue = createQueue;
|
|
5
|
+
const backoff_1 = require("./backoff");
|
|
6
|
+
const memory_1 = require("./storage/memory");
|
|
7
|
+
const id_1 = require("./utils/id");
|
|
8
|
+
class OfflineQueue {
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.tasks = [];
|
|
11
|
+
this.warnedAboutDedupe = false;
|
|
12
|
+
this.online = true;
|
|
13
|
+
this.running = false;
|
|
14
|
+
this.draining = false;
|
|
15
|
+
this.inFlight = 0;
|
|
16
|
+
this.timer = null;
|
|
17
|
+
this.listeners = {
|
|
18
|
+
enqueued: new Set(),
|
|
19
|
+
deduped: new Set(),
|
|
20
|
+
started: new Set(),
|
|
21
|
+
succeeded: new Set(),
|
|
22
|
+
failed: new Set(),
|
|
23
|
+
discarded: new Set(),
|
|
24
|
+
drained: new Set(),
|
|
25
|
+
changed: new Set(),
|
|
26
|
+
};
|
|
27
|
+
this.config = config;
|
|
28
|
+
this.storage = config.storage ?? (0, memory_1.createMemoryStorage)();
|
|
29
|
+
this.storageKey = config.storageKey ?? 'rn-offline-queue/v1';
|
|
30
|
+
this.retry = { ...backoff_1.DEFAULT_RETRY, ...config.retry };
|
|
31
|
+
this.dedupeStrategy = config.dedupeStrategy ?? 'replace';
|
|
32
|
+
this.concurrency = Math.max(1, config.concurrency ?? 1);
|
|
33
|
+
this.silenceDedupeWarning = config.silenceDedupeWarning ?? false;
|
|
34
|
+
this.running = config.autoStart ?? true;
|
|
35
|
+
this.hydrated = this.hydrate();
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------- lifecycle
|
|
38
|
+
/** Resolves once persisted tasks have been loaded from storage. */
|
|
39
|
+
ready() {
|
|
40
|
+
return this.hydrated;
|
|
41
|
+
}
|
|
42
|
+
/** Resume processing. */
|
|
43
|
+
start() {
|
|
44
|
+
if (this.running)
|
|
45
|
+
return;
|
|
46
|
+
this.running = true;
|
|
47
|
+
void this.drain();
|
|
48
|
+
}
|
|
49
|
+
/** Pause processing. In-flight tasks are allowed to finish. */
|
|
50
|
+
pause() {
|
|
51
|
+
this.running = false;
|
|
52
|
+
this.clearTimer();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Report connectivity. Wire this to NetInfo — the queue holds tasks while
|
|
56
|
+
* offline rather than burning retry attempts against a dead network.
|
|
57
|
+
*/
|
|
58
|
+
setOnline(online) {
|
|
59
|
+
const wasOffline = !this.online;
|
|
60
|
+
this.online = online;
|
|
61
|
+
if (online && wasOffline)
|
|
62
|
+
void this.drain();
|
|
63
|
+
}
|
|
64
|
+
isOnline() {
|
|
65
|
+
return this.online;
|
|
66
|
+
}
|
|
67
|
+
// ------------------------------------------------------------------- public
|
|
68
|
+
/** Add a mutation to the queue. Safe to call while offline. */
|
|
69
|
+
async enqueue(type, payload, options = {}) {
|
|
70
|
+
await this.hydrated;
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const task = {
|
|
73
|
+
id: (0, id_1.createId)(),
|
|
74
|
+
type,
|
|
75
|
+
payload,
|
|
76
|
+
createdAt: now,
|
|
77
|
+
updatedAt: now,
|
|
78
|
+
attempts: 0,
|
|
79
|
+
status: 'pending',
|
|
80
|
+
dedupeKey: options.dedupeKey,
|
|
81
|
+
};
|
|
82
|
+
const strategy = options.dedupeStrategy ?? this.dedupeStrategy;
|
|
83
|
+
if (task.dedupeKey && strategy !== 'keep') {
|
|
84
|
+
// Only collapse against tasks not yet in flight — replacing a running
|
|
85
|
+
// task would leave its side effect half-applied.
|
|
86
|
+
const existingIndex = this.tasks.findIndex((t) => t.dedupeKey === task.dedupeKey && t.status === 'pending');
|
|
87
|
+
if (existingIndex !== -1) {
|
|
88
|
+
const existing = this.tasks[existingIndex];
|
|
89
|
+
if (strategy === 'drop') {
|
|
90
|
+
this.noteDedupe();
|
|
91
|
+
this.emit('deduped', existing, task, strategy);
|
|
92
|
+
return existing;
|
|
93
|
+
}
|
|
94
|
+
// 'replace' — keep queue position, take the newer payload.
|
|
95
|
+
const merged = {
|
|
96
|
+
...existing,
|
|
97
|
+
payload: task.payload,
|
|
98
|
+
type: task.type,
|
|
99
|
+
updatedAt: now,
|
|
100
|
+
};
|
|
101
|
+
this.tasks[existingIndex] = merged;
|
|
102
|
+
await this.persist();
|
|
103
|
+
this.noteDedupe();
|
|
104
|
+
// `existing` carried the payload that is now gone.
|
|
105
|
+
this.emit('deduped', merged, existing, strategy);
|
|
106
|
+
this.emit('enqueued', merged);
|
|
107
|
+
void this.drain();
|
|
108
|
+
return merged;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.tasks.push(task);
|
|
112
|
+
await this.persist();
|
|
113
|
+
this.emit('enqueued', task);
|
|
114
|
+
void this.drain();
|
|
115
|
+
return task;
|
|
116
|
+
}
|
|
117
|
+
/** Snapshot of queued tasks, in execution order. */
|
|
118
|
+
list() {
|
|
119
|
+
return [...this.tasks];
|
|
120
|
+
}
|
|
121
|
+
size() {
|
|
122
|
+
return this.tasks.length;
|
|
123
|
+
}
|
|
124
|
+
/** Remove a single task without executing it. */
|
|
125
|
+
async remove(id) {
|
|
126
|
+
const before = this.tasks.length;
|
|
127
|
+
this.tasks = this.tasks.filter((t) => t.id !== id);
|
|
128
|
+
if (this.tasks.length === before)
|
|
129
|
+
return false;
|
|
130
|
+
await this.persist();
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
/** Drop every queued task. */
|
|
134
|
+
async clear() {
|
|
135
|
+
this.tasks = [];
|
|
136
|
+
await this.persist();
|
|
137
|
+
}
|
|
138
|
+
on(event, handler) {
|
|
139
|
+
const set = this.listeners[event];
|
|
140
|
+
set.add(handler);
|
|
141
|
+
return () => {
|
|
142
|
+
set.delete(handler);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// ------------------------------------------------------------------ internal
|
|
146
|
+
/**
|
|
147
|
+
* Deduplication discards work by design, but a silent discard is how the
|
|
148
|
+
* "my chat messages disappeared" bug happens. Warn once per queue in dev so
|
|
149
|
+
* the behaviour is discovered during development rather than in production.
|
|
150
|
+
*/
|
|
151
|
+
noteDedupe() {
|
|
152
|
+
if (this.warnedAboutDedupe || this.silenceDedupeWarning)
|
|
153
|
+
return;
|
|
154
|
+
this.warnedAboutDedupe = true;
|
|
155
|
+
const dev = globalThis.__DEV__;
|
|
156
|
+
if (dev === false)
|
|
157
|
+
return;
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.warn('[outbox] A queued task was collapsed because it shared a dedupeKey ' +
|
|
160
|
+
'with another. This is intentional for drafts and form saves, but it ' +
|
|
161
|
+
'DISCARDS the other payload — do not use dedupeKey for items that ' +
|
|
162
|
+
'must each be delivered, such as chat messages. Listen to the ' +
|
|
163
|
+
'"deduped" event to observe this, or pass silenceDedupeWarning: true.');
|
|
164
|
+
}
|
|
165
|
+
async hydrate() {
|
|
166
|
+
try {
|
|
167
|
+
const raw = await this.storage.getItem(this.storageKey);
|
|
168
|
+
if (raw) {
|
|
169
|
+
const parsed = JSON.parse(raw);
|
|
170
|
+
// Anything left mid-flight from a previous session is retried; the
|
|
171
|
+
// process died before we learned the outcome.
|
|
172
|
+
this.tasks = parsed.map((t) => t.status === 'running' ? { ...t, status: 'pending' } : t);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Corrupt payload should not brick the app — start clean.
|
|
177
|
+
this.tasks = [];
|
|
178
|
+
}
|
|
179
|
+
this.emit('changed', this.list());
|
|
180
|
+
void this.drain();
|
|
181
|
+
}
|
|
182
|
+
async persist() {
|
|
183
|
+
this.emit('changed', this.list());
|
|
184
|
+
try {
|
|
185
|
+
await this.storage.setItem(this.storageKey, JSON.stringify(this.tasks));
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Storage failure must not lose the in-memory queue.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
nextRunnable(now) {
|
|
192
|
+
return this.tasks.find((t) => t.status === 'pending' && (!t.nextAttemptAt || t.nextAttemptAt <= now));
|
|
193
|
+
}
|
|
194
|
+
async drain() {
|
|
195
|
+
if (this.draining)
|
|
196
|
+
return;
|
|
197
|
+
if (!this.running || !this.online)
|
|
198
|
+
return;
|
|
199
|
+
this.draining = true;
|
|
200
|
+
try {
|
|
201
|
+
while (this.running && this.online && this.inFlight < this.concurrency) {
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
const task = this.nextRunnable(now);
|
|
204
|
+
if (!task)
|
|
205
|
+
break;
|
|
206
|
+
void this.run(task);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
this.draining = false;
|
|
211
|
+
}
|
|
212
|
+
this.scheduleNext();
|
|
213
|
+
}
|
|
214
|
+
/** Wake up when the earliest backed-off task becomes eligible. */
|
|
215
|
+
scheduleNext() {
|
|
216
|
+
this.clearTimer();
|
|
217
|
+
if (!this.running || !this.online)
|
|
218
|
+
return;
|
|
219
|
+
const waiting = this.tasks
|
|
220
|
+
.filter((t) => t.status === 'pending' && t.nextAttemptAt)
|
|
221
|
+
.map((t) => t.nextAttemptAt);
|
|
222
|
+
if (waiting.length === 0) {
|
|
223
|
+
if (this.tasks.length === 0 && this.inFlight === 0)
|
|
224
|
+
this.emit('drained');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const delay = Math.max(0, Math.min(...waiting) - Date.now());
|
|
228
|
+
this.timer = setTimeout(() => {
|
|
229
|
+
this.timer = null;
|
|
230
|
+
void this.drain();
|
|
231
|
+
}, delay);
|
|
232
|
+
}
|
|
233
|
+
clearTimer() {
|
|
234
|
+
if (this.timer) {
|
|
235
|
+
clearTimeout(this.timer);
|
|
236
|
+
this.timer = null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async run(task) {
|
|
240
|
+
task.status = 'running';
|
|
241
|
+
task.attempts += 1;
|
|
242
|
+
task.updatedAt = Date.now();
|
|
243
|
+
this.inFlight += 1;
|
|
244
|
+
this.emit('started', task);
|
|
245
|
+
await this.persist();
|
|
246
|
+
try {
|
|
247
|
+
await this.config.execute(task);
|
|
248
|
+
this.tasks = this.tasks.filter((t) => t.id !== task.id);
|
|
249
|
+
await this.persist();
|
|
250
|
+
this.emit('succeeded', task);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
await this.handleFailure(task, error);
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
this.inFlight -= 1;
|
|
257
|
+
void this.drain();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async handleFailure(task, error) {
|
|
261
|
+
const kind = this.config.classifyError?.(error, task) ?? 'transient';
|
|
262
|
+
const exhausted = task.attempts >= this.retry.maxAttempts;
|
|
263
|
+
const willRetry = kind === 'transient' && !exhausted;
|
|
264
|
+
task.lastError = error instanceof Error ? error.message : String(error);
|
|
265
|
+
task.updatedAt = Date.now();
|
|
266
|
+
if (willRetry) {
|
|
267
|
+
task.status = 'pending';
|
|
268
|
+
task.nextAttemptAt = Date.now() + (0, backoff_1.computeBackoff)(task.attempts, this.retry);
|
|
269
|
+
await this.persist();
|
|
270
|
+
this.emit('failed', task, error, true);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
task.status = 'discarded';
|
|
274
|
+
this.tasks = this.tasks.filter((t) => t.id !== task.id);
|
|
275
|
+
await this.persist();
|
|
276
|
+
this.emit('failed', task, error, false);
|
|
277
|
+
this.emit('discarded', task, error);
|
|
278
|
+
try {
|
|
279
|
+
await this.config.onDiscard?.(task, error);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// A throwing conflict handler must not stall the queue.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
emit(event, ...args) {
|
|
286
|
+
for (const handler of this.listeners[event]) {
|
|
287
|
+
try {
|
|
288
|
+
handler(...args);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Listener errors are contained.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
exports.OfflineQueue = OfflineQueue;
|
|
297
|
+
function createQueue(config) {
|
|
298
|
+
return new OfflineQueue(config);
|
|
299
|
+
}
|
package/lib/react.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { OfflineQueue } from './queue';
|
|
2
|
+
import type { Task } from './types';
|
|
3
|
+
export interface QueueState<P = unknown> {
|
|
4
|
+
tasks: Task<P>[];
|
|
5
|
+
pending: number;
|
|
6
|
+
isOnline: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Subscribe a component to queue contents.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* const { pending, isOnline } = useOfflineQueue(queue);
|
|
13
|
+
* if (pending > 0) return <Text>{pending} change(s) waiting to sync</Text>;
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export declare function useOfflineQueue<P = unknown>(queue: OfflineQueue<P>): QueueState<P>;
|
package/lib/react.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useOfflineQueue = useOfflineQueue;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
/**
|
|
6
|
+
* Subscribe a component to queue contents.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const { pending, isOnline } = useOfflineQueue(queue);
|
|
10
|
+
* if (pending > 0) return <Text>{pending} change(s) waiting to sync</Text>;
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
function useOfflineQueue(queue) {
|
|
14
|
+
const [tasks, setTasks] = (0, react_1.useState)(() => queue.list());
|
|
15
|
+
const [isOnline, setIsOnline] = (0, react_1.useState)(() => queue.isOnline());
|
|
16
|
+
(0, react_1.useEffect)(() => {
|
|
17
|
+
setTasks(queue.list());
|
|
18
|
+
setIsOnline(queue.isOnline());
|
|
19
|
+
const unsubscribe = queue.on('changed', (next) => {
|
|
20
|
+
setTasks(next);
|
|
21
|
+
setIsOnline(queue.isOnline());
|
|
22
|
+
});
|
|
23
|
+
return unsubscribe;
|
|
24
|
+
}, [queue]);
|
|
25
|
+
return {
|
|
26
|
+
tasks,
|
|
27
|
+
pending: tasks.filter((t) => t.status !== 'discarded').length,
|
|
28
|
+
isOnline,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createMemoryStorage = createMemoryStorage;
|
|
4
|
+
/**
|
|
5
|
+
* In-memory adapter. Used as the default so the queue works without
|
|
6
|
+
* configuration, and useful in tests. Nothing survives a restart.
|
|
7
|
+
*/
|
|
8
|
+
function createMemoryStorage() {
|
|
9
|
+
const map = new Map();
|
|
10
|
+
return {
|
|
11
|
+
async getItem(key) {
|
|
12
|
+
return map.has(key) ? map.get(key) : null;
|
|
13
|
+
},
|
|
14
|
+
async setItem(key, value) {
|
|
15
|
+
map.set(key, value);
|
|
16
|
+
},
|
|
17
|
+
async removeItem(key) {
|
|
18
|
+
map.delete(key);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -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/lib/types.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the offline mutation queue.
|
|
3
|
+
*/
|
|
4
|
+
export type TaskStatus = 'pending' | 'running' | 'failed' | 'discarded';
|
|
5
|
+
export interface Task<P = unknown> {
|
|
6
|
+
/** Stable unique id, generated on enqueue. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Caller-defined operation name, e.g. `updateProfile`. */
|
|
9
|
+
type: string;
|
|
10
|
+
payload: P;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
updatedAt: number;
|
|
13
|
+
/** Number of execution attempts made so far. */
|
|
14
|
+
attempts: number;
|
|
15
|
+
status: TaskStatus;
|
|
16
|
+
/** Tasks sharing a key collapse according to the dedupe strategy. */
|
|
17
|
+
dedupeKey?: string;
|
|
18
|
+
/** Message from the most recent failure, kept for debugging. */
|
|
19
|
+
lastError?: string;
|
|
20
|
+
/** Epoch ms before which the task must not be retried. */
|
|
21
|
+
nextAttemptAt?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* What to do when a newly enqueued task shares a `dedupeKey` with one
|
|
25
|
+
* already waiting.
|
|
26
|
+
*
|
|
27
|
+
* - `replace` keeps the newest payload (last write wins) — the right default
|
|
28
|
+
* for "save this form" style mutations.
|
|
29
|
+
* - `drop` keeps the task already queued and ignores the new one.
|
|
30
|
+
* - `keep` disables collapsing and queues both.
|
|
31
|
+
*/
|
|
32
|
+
export type DedupeStrategy = 'replace' | 'drop' | 'keep';
|
|
33
|
+
export interface RetryPolicy {
|
|
34
|
+
/** Attempts before a task is handed to `onDiscard`. Default 5. */
|
|
35
|
+
maxAttempts: number;
|
|
36
|
+
/** First backoff delay in ms. Default 1000. */
|
|
37
|
+
baseDelayMs: number;
|
|
38
|
+
/** Upper bound for a single backoff delay in ms. Default 60_000. */
|
|
39
|
+
maxDelayMs: number;
|
|
40
|
+
/** Randomisation applied to each delay, 0–1. Default 0.3. */
|
|
41
|
+
jitter: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Thrown-error classification. Returning `permanent` skips remaining
|
|
45
|
+
* retries — use it for 4xx responses that will never succeed.
|
|
46
|
+
*/
|
|
47
|
+
export type FailureKind = 'transient' | 'permanent';
|
|
48
|
+
export interface QueueEvents<P = unknown> {
|
|
49
|
+
enqueued: (task: Task<P>) => void;
|
|
50
|
+
/**
|
|
51
|
+
* A task was collapsed into another because they shared a `dedupeKey`.
|
|
52
|
+
* `kept` remains queued; `dropped` was discarded.
|
|
53
|
+
*
|
|
54
|
+
* Deduplication is intentional, but it does throw work away. Listen here to
|
|
55
|
+
* log or reconcile — and to catch the classic mistake of giving a dedupeKey
|
|
56
|
+
* to things that must each be delivered, such as chat messages.
|
|
57
|
+
*/
|
|
58
|
+
deduped: (kept: Task<P>, dropped: Task<P>, strategy: DedupeStrategy) => void;
|
|
59
|
+
started: (task: Task<P>) => void;
|
|
60
|
+
succeeded: (task: Task<P>) => void;
|
|
61
|
+
failed: (task: Task<P>, error: unknown, willRetry: boolean) => void;
|
|
62
|
+
discarded: (task: Task<P>, error: unknown) => void;
|
|
63
|
+
drained: () => void;
|
|
64
|
+
/** Fired whenever the persisted task list changes. */
|
|
65
|
+
changed: (tasks: Task<P>[]) => void;
|
|
66
|
+
}
|
|
67
|
+
export type QueueEventName = keyof QueueEvents;
|
|
68
|
+
export type Unsubscribe = () => void;
|
package/lib/types.js
ADDED
package/lib/utils/id.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createId = createId;
|
|
4
|
+
let counter = 0;
|
|
5
|
+
/**
|
|
6
|
+
* Collision-resistant id without pulling in a uuid dependency.
|
|
7
|
+
* Time-prefixed so ids sort roughly in creation order.
|
|
8
|
+
*/
|
|
9
|
+
function createId() {
|
|
10
|
+
counter = (counter + 1) % 0xffff;
|
|
11
|
+
const time = Date.now().toString(36);
|
|
12
|
+
const seq = counter.toString(36).padStart(3, '0');
|
|
13
|
+
const rand = Math.floor(Math.random() * 0xffffff)
|
|
14
|
+
.toString(36)
|
|
15
|
+
.padStart(4, '0');
|
|
16
|
+
return `${time}-${seq}-${rand}`;
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-outbox-mutation-queue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A focused offline mutation queue for React Native \u2014 queue API writes while offline, retry with exponential backoff, deduplicate, persist across restarts.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./lib/index.d.ts",
|
|
10
|
+
"default": "./lib/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./react": {
|
|
13
|
+
"types": "./lib/react.d.ts",
|
|
14
|
+
"default": "./lib/react.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"lib",
|
|
19
|
+
"src",
|
|
20
|
+
"!src/**/*.test.ts",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"prepublishOnly": "npm run build",
|
|
31
|
+
"demo": "node example/node-demo.cjs"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"react-native",
|
|
35
|
+
"offline",
|
|
36
|
+
"offline-first",
|
|
37
|
+
"queue",
|
|
38
|
+
"mutation",
|
|
39
|
+
"retry",
|
|
40
|
+
"backoff",
|
|
41
|
+
"sync",
|
|
42
|
+
"async-storage"
|
|
43
|
+
],
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"sideEffects": false,
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"react": ">=17.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"react": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/react": "^19.2.18",
|
|
56
|
+
"typescript": "^5.6.0",
|
|
57
|
+
"vitest": "^2.1.0"
|
|
58
|
+
},
|
|
59
|
+
"repository": {
|
|
60
|
+
"type": "git",
|
|
61
|
+
"url": "git+https://github.com/ARBAB1/react-native-outbox-mutation-queue.git"
|
|
62
|
+
},
|
|
63
|
+
"bugs": {
|
|
64
|
+
"url": "https://github.com/ARBAB1/react-native-outbox-mutation-queue/issues"
|
|
65
|
+
},
|
|
66
|
+
"homepage": "https://github.com/ARBAB1/react-native-outbox-mutation-queue#readme",
|
|
67
|
+
"author": "Syed Arbab Ali Shah <syedarbabalishah@gmail.com>"
|
|
68
|
+
}
|
package/src/backoff.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RetryPolicy } from './types';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_RETRY: RetryPolicy = {
|
|
4
|
+
maxAttempts: 5,
|
|
5
|
+
baseDelayMs: 1000,
|
|
6
|
+
maxDelayMs: 60_000,
|
|
7
|
+
jitter: 0.3,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Exponential backoff with full-width jitter.
|
|
12
|
+
*
|
|
13
|
+
* Delay grows as base * 2^(attempt-1), clamped to maxDelayMs, then has a
|
|
14
|
+
* random factor applied so a fleet of devices coming back online together
|
|
15
|
+
* does not retry in lockstep and stampede the API.
|
|
16
|
+
*
|
|
17
|
+
* @param attempt 1-based attempt number that just failed.
|
|
18
|
+
*/
|
|
19
|
+
export function computeBackoff(
|
|
20
|
+
attempt: number,
|
|
21
|
+
policy: RetryPolicy = DEFAULT_RETRY,
|
|
22
|
+
random: () => number = Math.random,
|
|
23
|
+
): number {
|
|
24
|
+
const exponent = Math.max(0, attempt - 1);
|
|
25
|
+
const raw = policy.baseDelayMs * 2 ** exponent;
|
|
26
|
+
const clamped = Math.min(raw, policy.maxDelayMs);
|
|
27
|
+
|
|
28
|
+
if (policy.jitter <= 0) return Math.round(clamped);
|
|
29
|
+
|
|
30
|
+
// Spread within ±jitter of the clamped delay, never below zero.
|
|
31
|
+
const spread = clamped * policy.jitter;
|
|
32
|
+
const offset = (random() * 2 - 1) * spread;
|
|
33
|
+
return Math.max(0, Math.round(clamped + offset));
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { OfflineQueue, createQueue } from './queue';
|
|
2
|
+
export type { QueueConfig, EnqueueOptions } from './queue';
|
|
3
|
+
|
|
4
|
+
export { computeBackoff, DEFAULT_RETRY } from './backoff';
|
|
5
|
+
export { createMemoryStorage } from './storage/memory';
|
|
6
|
+
|
|
7
|
+
export type { StorageAdapter } from './storage/types';
|
|
8
|
+
export type {
|
|
9
|
+
DedupeStrategy,
|
|
10
|
+
FailureKind,
|
|
11
|
+
QueueEventName,
|
|
12
|
+
QueueEvents,
|
|
13
|
+
RetryPolicy,
|
|
14
|
+
Task,
|
|
15
|
+
TaskStatus,
|
|
16
|
+
Unsubscribe,
|
|
17
|
+
} from './types';
|