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/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;
|
package/src/utils/id.ts
ADDED
|
@@ -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
|
+
}
|