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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
This project adheres to [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- `deduped(kept, dropped, strategy)` event so collapsing a task is observable
|
|
10
|
+
rather than silent
|
|
11
|
+
- One-time development warning the first time deduplication discards a
|
|
12
|
+
payload, with `silenceDedupeWarning` to opt out
|
|
13
|
+
- Chat recipe in the guide, including why `dedupeKey` must not be used for
|
|
14
|
+
messages
|
|
15
|
+
|
|
16
|
+
## [0.1.0] - 2026-09-03
|
|
17
|
+
|
|
18
|
+
Initial release.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- Persistent offline mutation queue with a storage-agnostic adapter interface
|
|
22
|
+
- Exponential backoff with jitter and a configurable retry policy
|
|
23
|
+
- Deduplication strategies: `replace`, `drop`, `keep`
|
|
24
|
+
- `classifyError` to separate permanent failures from transient ones
|
|
25
|
+
- `onDiscard` conflict hook for exhausted or permanently failed tasks
|
|
26
|
+
- Connectivity control via `setOnline`, with no NetInfo dependency
|
|
27
|
+
- Interrupted in-flight tasks restored as pending on relaunch
|
|
28
|
+
- `useOfflineQueue` React hook for rendering sync state
|
|
29
|
+
- Runnable Node demo and an Expo example app
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Syed Arbab Ali Shah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# react-native-outbox-mutation-queue
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/react-native-outbox-mutation-queue)
|
|
4
|
+
[](https://github.com/ARBAB1/react-native-outbox-mutation-queue/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](./src/types.ts)
|
|
7
|
+
[](./package.json)
|
|
8
|
+
|
|
9
|
+
**The user tapped Save on a train. Now what?**
|
|
10
|
+
|
|
11
|
+
A focused offline mutation queue for React Native. It holds writes while the
|
|
12
|
+
device is offline, retries them with exponential backoff when it comes back,
|
|
13
|
+
collapses duplicates, and survives app restarts.
|
|
14
|
+
|
|
15
|
+
It is deliberately **not** a database sync engine.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
await queue.enqueue('updateProfile', { name: 'Ada' }, { dedupeKey: 'profile' });
|
|
19
|
+
// Offline? Queued. Online? Sent. Failed? Retried. App killed? Still there.
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## The problem
|
|
25
|
+
|
|
26
|
+
Imagine your app has a "Save" button.
|
|
27
|
+
|
|
28
|
+
Normally when someone taps Save, your app sends the data to the server right
|
|
29
|
+
then:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
User taps Save → app sends to server → server replies "ok" → done
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
But what if there is no internet at that moment? In a lift, on the metro, in a
|
|
36
|
+
basement, bad signal.
|
|
37
|
+
|
|
38
|
+
The send fails. Your app shows "Error, try again." The user has already put
|
|
39
|
+
their phone in their pocket. **Their data is gone.**
|
|
40
|
+
|
|
41
|
+
## What this library does
|
|
42
|
+
|
|
43
|
+
It sits in between:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
User taps Save → library stores it on the phone → shows "Saved" instantly
|
|
47
|
+
↓
|
|
48
|
+
(waits until internet comes back)
|
|
49
|
+
↓
|
|
50
|
+
sends to server automatically
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The user never sees an error. They do not have to retry. It just gets
|
|
54
|
+
delivered whenever the connection returns — even if they close the app
|
|
55
|
+
completely and open it tomorrow.
|
|
56
|
+
|
|
57
|
+
That is it. That is the whole idea.
|
|
58
|
+
|
|
59
|
+
## What it handles for you
|
|
60
|
+
|
|
61
|
+
| Situation | What happens |
|
|
62
|
+
|---|---|
|
|
63
|
+
| **No internet** | Holds the data safely on the phone |
|
|
64
|
+
| **Internet comes back** | Sends it automatically |
|
|
65
|
+
| **Server is down** | Waits and tries again — 1s, then 2s, 4s, 8s. Not hammering it |
|
|
66
|
+
| **Server says "invalid"** | Stops trying, tells your app |
|
|
67
|
+
| **User saved 10 times quickly** | Sends only the last version, not all 10 |
|
|
68
|
+
| **User force-closed the app** | Still there when they reopen |
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Why this exists
|
|
73
|
+
|
|
74
|
+
Most offline problems in a mobile app are not "replicate my database." They
|
|
75
|
+
are one narrow thing: **a mutation fired at a moment when the network was not
|
|
76
|
+
there, and it must not be lost.**
|
|
77
|
+
|
|
78
|
+
Good options already exist — they just all come with a commitment:
|
|
79
|
+
|
|
80
|
+
| Package | Weekly downloads | What it asks of you |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| [`@tanstack/react-query`](https://tanstack.com/query) | ~65M | Adopt React Query for your data layer |
|
|
83
|
+
| [`@redux-offline/redux-offline`](https://github.com/redux-offline/redux-offline) | ~100k | Adopt Redux |
|
|
84
|
+
| [`rxdb`](https://rxdb.info) | ~73k | Adopt their database |
|
|
85
|
+
| [`@nozbe/watermelondb`](https://watermelondb.dev) | ~61k | Adopt their database |
|
|
86
|
+
| [`@powersync/react-native`](https://powersync.com) | ~38k | Adopt their sync backend |
|
|
87
|
+
| [`react-native-offline`](https://github.com/rgommezz/react-native-offline) | ~11k | Redux-coupled; **last published Feb 2023** |
|
|
88
|
+
| **this package** | — | Nothing. One function |
|
|
89
|
+
|
|
90
|
+
*Figures collected September 2026.*
|
|
91
|
+
|
|
92
|
+
**Be honest with yourself before installing this:**
|
|
93
|
+
|
|
94
|
+
- Already using **React Query**? Use its
|
|
95
|
+
[`persistQueryClient` with mutation resume](https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient).
|
|
96
|
+
It is excellent and you already have it.
|
|
97
|
+
- Already using **Redux**? `@redux-offline/redux-offline` is mature and
|
|
98
|
+
actively maintained.
|
|
99
|
+
- Need a **replicated local database**? WatermelonDB, RxDB or PowerSync.
|
|
100
|
+
|
|
101
|
+
**This package is for the case none of those fit:** no Redux, no React Query,
|
|
102
|
+
no local database — and no appetite for adopting one just so a `POST` survives
|
|
103
|
+
a tunnel. Zero runtime dependencies, one job.
|
|
104
|
+
|
|
105
|
+
There is a second reason it exists. `react-native-offline` still sees ~11k
|
|
106
|
+
downloads a week but has not shipped since **February 2023**. If you are on it
|
|
107
|
+
for the queue alone, this is a smaller, maintained, Redux-free replacement.
|
|
108
|
+
|
|
109
|
+
📖 **[Full guide](docs/GUIDE.md)** — how it works, platform setup, permissions, idempotency and troubleshooting.
|
|
110
|
+
|
|
111
|
+
## Install
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
npm install react-native-outbox-mutation-queue
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
No required runtime dependencies. Persistence and connectivity are injected,
|
|
118
|
+
so nothing is bundled that you might not use.
|
|
119
|
+
|
|
120
|
+
## Quick start
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { createQueue } from 'react-native-outbox-mutation-queue';
|
|
124
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
125
|
+
import NetInfo from '@react-native-community/netinfo';
|
|
126
|
+
|
|
127
|
+
export const queue = createQueue({
|
|
128
|
+
storage: AsyncStorage,
|
|
129
|
+
|
|
130
|
+
async execute(task) {
|
|
131
|
+
const res = await fetch(`https://api.example.com/${task.type}`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: { 'Content-Type': 'application/json' },
|
|
134
|
+
body: JSON.stringify(task.payload),
|
|
135
|
+
});
|
|
136
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// 4xx will never succeed on retry — stop immediately.
|
|
140
|
+
classifyError(error) {
|
|
141
|
+
const status = Number(String(error).match(/HTTP (\d+)/)?.[1]);
|
|
142
|
+
return status >= 400 && status < 500 ? 'permanent' : 'transient';
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
// Retries exhausted, or a permanent failure. Reconcile here.
|
|
146
|
+
onDiscard(task, error) {
|
|
147
|
+
console.warn('Dropped', task.type, error);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
NetInfo.addEventListener((state) => {
|
|
152
|
+
queue.setOnline(Boolean(state.isConnected));
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Then queue writes from anywhere:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
await queue.enqueue('updateProfile', { name: 'Ada' });
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Showing sync state
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
import { useOfflineQueue } from 'react-native-outbox-mutation-queue/react';
|
|
166
|
+
|
|
167
|
+
function SyncBadge() {
|
|
168
|
+
const { pending, isOnline } = useOfflineQueue(queue);
|
|
169
|
+
if (pending === 0) return null;
|
|
170
|
+
return (
|
|
171
|
+
<Text>
|
|
172
|
+
{pending} change{pending === 1 ? '' : 's'} waiting
|
|
173
|
+
{isOnline ? ' — syncing…' : ' — offline'}
|
|
174
|
+
</Text>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Deduplication
|
|
180
|
+
|
|
181
|
+
A user editing a form offline generates a write per keystroke-save. You almost
|
|
182
|
+
never want to replay all of them.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
await queue.enqueue('saveDraft', { text: 'a' }, { dedupeKey: 'draft:42' });
|
|
186
|
+
await queue.enqueue('saveDraft', { text: 'ab' }, { dedupeKey: 'draft:42' });
|
|
187
|
+
await queue.enqueue('saveDraft', { text: 'abc' }, { dedupeKey: 'draft:42' });
|
|
188
|
+
// One task is queued, carrying { text: 'abc' }.
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
| Strategy | Behaviour |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `replace` *(default)* | Keep queue position, take the newest payload |
|
|
194
|
+
| `drop` | Keep the task already queued, ignore the new one |
|
|
195
|
+
| `keep` | Do not collapse — queue every task |
|
|
196
|
+
|
|
197
|
+
Only **pending** tasks collapse. A task already in flight is never replaced —
|
|
198
|
+
that would leave its side effect half-applied.
|
|
199
|
+
|
|
200
|
+
⚠️ **Deduplication throws work away.** That is the point for drafts, and a
|
|
201
|
+
data-loss bug for anything that must each be delivered — chat messages above
|
|
202
|
+
all. Two safeguards:
|
|
203
|
+
|
|
204
|
+
- The queue emits **`deduped(kept, dropped, strategy)`** so you can observe it
|
|
205
|
+
- It logs a **one-time warning** the first time a collapse happens; pass
|
|
206
|
+
`silenceDedupeWarning: true` once you have confirmed it is intended
|
|
207
|
+
|
|
208
|
+
Tasks without a `dedupeKey` are never collapsed.
|
|
209
|
+
|
|
210
|
+
## Retries
|
|
211
|
+
|
|
212
|
+
Exponential backoff with jitter, so a fleet of devices reconnecting together
|
|
213
|
+
does not stampede your API.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
retry: {
|
|
217
|
+
maxAttempts: 5,
|
|
218
|
+
baseDelayMs: 1000,
|
|
219
|
+
maxDelayMs: 60_000,
|
|
220
|
+
jitter: 0.3,
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Delays follow `base × 2^(n-1)`, clamped to `maxDelayMs`, then randomised
|
|
225
|
+
within ±`jitter`. Default schedule: **1s → 2s → 4s → 8s → 16s**.
|
|
226
|
+
|
|
227
|
+
## API
|
|
228
|
+
|
|
229
|
+
### `createQueue(config)`
|
|
230
|
+
|
|
231
|
+
| Option | Default | Purpose |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| `execute` | *required* | Performs the side effect. Throw to retry |
|
|
234
|
+
| `storage` | in-memory | Any AsyncStorage-shaped adapter |
|
|
235
|
+
| `storageKey` | `rn-offline-queue/v1` | Persistence key |
|
|
236
|
+
| `retry` | see above | Backoff policy |
|
|
237
|
+
| `dedupeStrategy` | `replace` | Default collapsing behaviour |
|
|
238
|
+
| `concurrency` | `1` | Parallel executions; `1` preserves order |
|
|
239
|
+
| `classifyError` | `transient` | Return `permanent` to stop retrying |
|
|
240
|
+
| `onDiscard` | — | Conflict hook: retries exhausted or permanent |
|
|
241
|
+
| `autoStart` | `true` | Begin processing immediately |
|
|
242
|
+
| `silenceDedupeWarning` | `false` | Suppress the one-time dedupe warning |
|
|
243
|
+
|
|
244
|
+
### Methods
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
queue.ready() // resolves once storage is hydrated
|
|
248
|
+
queue.enqueue(type, payload, opts) // add a mutation
|
|
249
|
+
queue.list() // snapshot, in execution order
|
|
250
|
+
queue.size()
|
|
251
|
+
queue.remove(id)
|
|
252
|
+
queue.clear()
|
|
253
|
+
queue.start() / queue.pause()
|
|
254
|
+
queue.setOnline(boolean)
|
|
255
|
+
queue.on(event, handler) // returns an unsubscribe function
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Events
|
|
259
|
+
|
|
260
|
+
`enqueued` · `deduped` · `started` · `succeeded` · `failed` · `discarded` ·
|
|
261
|
+
`drained` · `changed`
|
|
262
|
+
|
|
263
|
+
`deduped(kept, dropped, strategy)` fires whenever a task is collapsed, naming
|
|
264
|
+
the payload that was discarded — so the discard is observable rather than
|
|
265
|
+
silent.
|
|
266
|
+
|
|
267
|
+
## Using it for chat
|
|
268
|
+
|
|
269
|
+
Sending messages is one of the best fits for this library — the WhatsApp
|
|
270
|
+
clock-icon behaviour. But chat needs different settings, and one default is
|
|
271
|
+
actively dangerous:
|
|
272
|
+
|
|
273
|
+
⚠️ **Never use `dedupeKey` for messages.** Dedupe collapses tasks sharing a
|
|
274
|
+
key, which for chat means **deleted messages** — send three, two vanish.
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
queue.enqueue('sendMessage', msg); // ✅ no dedupeKey
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Also raise `maxAttempts` (users expect messages to keep trying for hours),
|
|
281
|
+
keep `concurrency: 1` for ordering, and send a device-generated message id as
|
|
282
|
+
an idempotency key so retries do not post twice.
|
|
283
|
+
|
|
284
|
+
This only covers **sending**. Receiving still needs a WebSocket or push
|
|
285
|
+
notifications.
|
|
286
|
+
|
|
287
|
+
📖 [Full chat recipe](docs/GUIDE.md#recipe-using-it-for-chat)
|
|
288
|
+
|
|
289
|
+
## Behaviour worth knowing
|
|
290
|
+
|
|
291
|
+
**Ordering.** With the default `concurrency: 1`, tasks execute strictly in
|
|
292
|
+
enqueue order. Raise it only when your mutations are genuinely independent.
|
|
293
|
+
|
|
294
|
+
**Interrupted tasks.** If the process dies mid-flight, that task is restored
|
|
295
|
+
as `pending` and retried on next launch. Your `execute` should therefore be
|
|
296
|
+
**idempotent** — send an idempotency key if the API supports one.
|
|
297
|
+
|
|
298
|
+
**Storage failures are non-fatal.** If persistence throws, the in-memory queue
|
|
299
|
+
keeps working; you lose durability, not the queue.
|
|
300
|
+
|
|
301
|
+
**Corrupt state self-heals.** Unparseable stored data is discarded rather than
|
|
302
|
+
crashing on launch.
|
|
303
|
+
|
|
304
|
+
## Custom storage
|
|
305
|
+
|
|
306
|
+
Anything with three methods works — MMKV, SQLite, a test double:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
import { MMKV } from 'react-native-mmkv';
|
|
310
|
+
const mmkv = new MMKV();
|
|
311
|
+
|
|
312
|
+
const storage = {
|
|
313
|
+
async getItem(k) { return mmkv.getString(k) ?? null; },
|
|
314
|
+
async setItem(k, v) { mmkv.set(k, v); },
|
|
315
|
+
async removeItem(k) { mmkv.delete(k); },
|
|
316
|
+
};
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Development
|
|
320
|
+
|
|
321
|
+
```sh
|
|
322
|
+
npm install
|
|
323
|
+
npm test
|
|
324
|
+
npm run typecheck
|
|
325
|
+
npm run build
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Contributing
|
|
329
|
+
|
|
330
|
+
Issues and pull requests are welcome. Run `npm test` and `npm run typecheck`
|
|
331
|
+
before opening a PR.
|
|
332
|
+
|
|
333
|
+
## Support this project
|
|
334
|
+
|
|
335
|
+
If this saved you an afternoon, you can say thanks:
|
|
336
|
+
|
|
337
|
+
<a href="https://www.buymeacoffee.com/arbab1" target="_blank">
|
|
338
|
+
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="46" width="163">
|
|
339
|
+
</a>
|
|
340
|
+
|
|
341
|
+
Starring the repo helps too — it is how other developers find it.
|
|
342
|
+
|
|
343
|
+
## License
|
|
344
|
+
|
|
345
|
+
MIT © [Syed Arbab Ali Shah](https://github.com/ARBAB1)
|
package/lib/backoff.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RetryPolicy } from './types';
|
|
2
|
+
export declare const DEFAULT_RETRY: RetryPolicy;
|
|
3
|
+
/**
|
|
4
|
+
* Exponential backoff with full-width jitter.
|
|
5
|
+
*
|
|
6
|
+
* Delay grows as base * 2^(attempt-1), clamped to maxDelayMs, then has a
|
|
7
|
+
* random factor applied so a fleet of devices coming back online together
|
|
8
|
+
* does not retry in lockstep and stampede the API.
|
|
9
|
+
*
|
|
10
|
+
* @param attempt 1-based attempt number that just failed.
|
|
11
|
+
*/
|
|
12
|
+
export declare function computeBackoff(attempt: number, policy?: RetryPolicy, random?: () => number): number;
|
package/lib/backoff.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_RETRY = void 0;
|
|
4
|
+
exports.computeBackoff = computeBackoff;
|
|
5
|
+
exports.DEFAULT_RETRY = {
|
|
6
|
+
maxAttempts: 5,
|
|
7
|
+
baseDelayMs: 1000,
|
|
8
|
+
maxDelayMs: 60000,
|
|
9
|
+
jitter: 0.3,
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Exponential backoff with full-width jitter.
|
|
13
|
+
*
|
|
14
|
+
* Delay grows as base * 2^(attempt-1), clamped to maxDelayMs, then has a
|
|
15
|
+
* random factor applied so a fleet of devices coming back online together
|
|
16
|
+
* does not retry in lockstep and stampede the API.
|
|
17
|
+
*
|
|
18
|
+
* @param attempt 1-based attempt number that just failed.
|
|
19
|
+
*/
|
|
20
|
+
function computeBackoff(attempt, policy = exports.DEFAULT_RETRY, random = Math.random) {
|
|
21
|
+
const exponent = Math.max(0, attempt - 1);
|
|
22
|
+
const raw = policy.baseDelayMs * 2 ** exponent;
|
|
23
|
+
const clamped = Math.min(raw, policy.maxDelayMs);
|
|
24
|
+
if (policy.jitter <= 0)
|
|
25
|
+
return Math.round(clamped);
|
|
26
|
+
// Spread within ±jitter of the clamped delay, never below zero.
|
|
27
|
+
const spread = clamped * policy.jitter;
|
|
28
|
+
const offset = (random() * 2 - 1) * spread;
|
|
29
|
+
return Math.max(0, Math.round(clamped + offset));
|
|
30
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { OfflineQueue, createQueue } from './queue';
|
|
2
|
+
export type { QueueConfig, EnqueueOptions } from './queue';
|
|
3
|
+
export { computeBackoff, DEFAULT_RETRY } from './backoff';
|
|
4
|
+
export { createMemoryStorage } from './storage/memory';
|
|
5
|
+
export type { StorageAdapter } from './storage/types';
|
|
6
|
+
export type { DedupeStrategy, FailureKind, QueueEventName, QueueEvents, RetryPolicy, Task, TaskStatus, Unsubscribe, } from './types';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createMemoryStorage = exports.DEFAULT_RETRY = exports.computeBackoff = exports.createQueue = exports.OfflineQueue = void 0;
|
|
4
|
+
var queue_1 = require("./queue");
|
|
5
|
+
Object.defineProperty(exports, "OfflineQueue", { enumerable: true, get: function () { return queue_1.OfflineQueue; } });
|
|
6
|
+
Object.defineProperty(exports, "createQueue", { enumerable: true, get: function () { return queue_1.createQueue; } });
|
|
7
|
+
var backoff_1 = require("./backoff");
|
|
8
|
+
Object.defineProperty(exports, "computeBackoff", { enumerable: true, get: function () { return backoff_1.computeBackoff; } });
|
|
9
|
+
Object.defineProperty(exports, "DEFAULT_RETRY", { enumerable: true, get: function () { return backoff_1.DEFAULT_RETRY; } });
|
|
10
|
+
var memory_1 = require("./storage/memory");
|
|
11
|
+
Object.defineProperty(exports, "createMemoryStorage", { enumerable: true, get: function () { return memory_1.createMemoryStorage; } });
|
package/lib/queue.d.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { StorageAdapter } from './storage/types';
|
|
2
|
+
import type { DedupeStrategy, FailureKind, QueueEventName, QueueEvents, RetryPolicy, Task, Unsubscribe } from './types';
|
|
3
|
+
export interface EnqueueOptions {
|
|
4
|
+
/** Tasks sharing this key collapse per `dedupeStrategy`. */
|
|
5
|
+
dedupeKey?: string;
|
|
6
|
+
/** Overrides the queue-level strategy for this task only. */
|
|
7
|
+
dedupeStrategy?: DedupeStrategy;
|
|
8
|
+
}
|
|
9
|
+
export interface QueueConfig<P = unknown> {
|
|
10
|
+
/**
|
|
11
|
+
* Performs the actual side effect — usually an API call. Resolving marks
|
|
12
|
+
* the task done; throwing schedules a retry.
|
|
13
|
+
*/
|
|
14
|
+
execute: (task: Task<P>) => Promise<void>;
|
|
15
|
+
/** Where tasks are persisted. Defaults to in-memory (non-durable). */
|
|
16
|
+
storage?: StorageAdapter;
|
|
17
|
+
/** Key under which the task list is stored. */
|
|
18
|
+
storageKey?: string;
|
|
19
|
+
retry?: Partial<RetryPolicy>;
|
|
20
|
+
/** Default collapsing behaviour for tasks carrying a `dedupeKey`. */
|
|
21
|
+
dedupeStrategy?: DedupeStrategy;
|
|
22
|
+
/** How many tasks may run at once. Default 1 (strict ordering). */
|
|
23
|
+
concurrency?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Classifies a thrown error. Returning `permanent` stops retrying
|
|
26
|
+
* immediately — use it for 4xx responses that will never succeed.
|
|
27
|
+
*/
|
|
28
|
+
classifyError?: (error: unknown, task: Task<P>) => FailureKind;
|
|
29
|
+
/**
|
|
30
|
+
* Called when a task exhausts its retries or fails permanently. This is
|
|
31
|
+
* the conflict hook: reconcile server state, surface a prompt, or drop it.
|
|
32
|
+
*/
|
|
33
|
+
onDiscard?: (task: Task<P>, error: unknown) => void | Promise<void>;
|
|
34
|
+
/** Start processing as soon as the queue is constructed. Default true. */
|
|
35
|
+
autoStart?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Suppress the one-time development warning emitted the first time a task
|
|
38
|
+
* is collapsed by `dedupeKey`. Set this once you have confirmed the
|
|
39
|
+
* collapsing is intended.
|
|
40
|
+
*/
|
|
41
|
+
silenceDedupeWarning?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export declare class OfflineQueue<P = unknown> {
|
|
44
|
+
private tasks;
|
|
45
|
+
private readonly storage;
|
|
46
|
+
private readonly storageKey;
|
|
47
|
+
private readonly retry;
|
|
48
|
+
private readonly dedupeStrategy;
|
|
49
|
+
private readonly concurrency;
|
|
50
|
+
private readonly config;
|
|
51
|
+
private readonly silenceDedupeWarning;
|
|
52
|
+
private warnedAboutDedupe;
|
|
53
|
+
private online;
|
|
54
|
+
private running;
|
|
55
|
+
private draining;
|
|
56
|
+
private inFlight;
|
|
57
|
+
private timer;
|
|
58
|
+
private hydrated;
|
|
59
|
+
private listeners;
|
|
60
|
+
constructor(config: QueueConfig<P>);
|
|
61
|
+
/** Resolves once persisted tasks have been loaded from storage. */
|
|
62
|
+
ready(): Promise<void>;
|
|
63
|
+
/** Resume processing. */
|
|
64
|
+
start(): void;
|
|
65
|
+
/** Pause processing. In-flight tasks are allowed to finish. */
|
|
66
|
+
pause(): void;
|
|
67
|
+
/**
|
|
68
|
+
* Report connectivity. Wire this to NetInfo — the queue holds tasks while
|
|
69
|
+
* offline rather than burning retry attempts against a dead network.
|
|
70
|
+
*/
|
|
71
|
+
setOnline(online: boolean): void;
|
|
72
|
+
isOnline(): boolean;
|
|
73
|
+
/** Add a mutation to the queue. Safe to call while offline. */
|
|
74
|
+
enqueue(type: string, payload: P, options?: EnqueueOptions): Promise<Task<P>>;
|
|
75
|
+
/** Snapshot of queued tasks, in execution order. */
|
|
76
|
+
list(): Task<P>[];
|
|
77
|
+
size(): number;
|
|
78
|
+
/** Remove a single task without executing it. */
|
|
79
|
+
remove(id: string): Promise<boolean>;
|
|
80
|
+
/** Drop every queued task. */
|
|
81
|
+
clear(): Promise<void>;
|
|
82
|
+
on<K extends QueueEventName>(event: K, handler: QueueEvents<P>[K]): Unsubscribe;
|
|
83
|
+
/**
|
|
84
|
+
* Deduplication discards work by design, but a silent discard is how the
|
|
85
|
+
* "my chat messages disappeared" bug happens. Warn once per queue in dev so
|
|
86
|
+
* the behaviour is discovered during development rather than in production.
|
|
87
|
+
*/
|
|
88
|
+
private noteDedupe;
|
|
89
|
+
private hydrate;
|
|
90
|
+
private persist;
|
|
91
|
+
private nextRunnable;
|
|
92
|
+
private drain;
|
|
93
|
+
/** Wake up when the earliest backed-off task becomes eligible. */
|
|
94
|
+
private scheduleNext;
|
|
95
|
+
private clearTimer;
|
|
96
|
+
private run;
|
|
97
|
+
private handleFailure;
|
|
98
|
+
private emit;
|
|
99
|
+
}
|
|
100
|
+
export declare function createQueue<P = unknown>(config: QueueConfig<P>): OfflineQueue<P>;
|