@mustafaaksoy41/react-native-offline-queue 0.1.2
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/LICENSE +21 -0
- package/README.md +673 -0
- package/lib/commonjs/adapters/index.js +128 -0
- package/lib/commonjs/adapters/index.js.map +1 -0
- package/lib/commonjs/components/OfflineProvider.js +51 -0
- package/lib/commonjs/components/OfflineProvider.js.map +1 -0
- package/lib/commonjs/components/OfflineSyncPrompt.js +37 -0
- package/lib/commonjs/components/OfflineSyncPrompt.js.map +1 -0
- package/lib/commonjs/core/OfflineManager.js +308 -0
- package/lib/commonjs/core/OfflineManager.js.map +1 -0
- package/lib/commonjs/core/StorageAdapter.js +31 -0
- package/lib/commonjs/core/StorageAdapter.js.map +1 -0
- package/lib/commonjs/core/types.js +15 -0
- package/lib/commonjs/core/types.js.map +1 -0
- package/lib/commonjs/global.d.js +2 -0
- package/lib/commonjs/global.d.js.map +1 -0
- package/lib/commonjs/hooks/useOfflineMutation.js +61 -0
- package/lib/commonjs/hooks/useOfflineMutation.js.map +1 -0
- package/lib/commonjs/hooks/useOfflineQueue.js +21 -0
- package/lib/commonjs/hooks/useOfflineQueue.js.map +1 -0
- package/lib/commonjs/hooks/useOfflineSyncInterceptor.js +42 -0
- package/lib/commonjs/hooks/useOfflineSyncInterceptor.js.map +1 -0
- package/lib/commonjs/hooks/useSyncProgress.js +33 -0
- package/lib/commonjs/hooks/useSyncProgress.js.map +1 -0
- package/lib/commonjs/index.js +134 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/module/adapters/index.js +121 -0
- package/lib/module/adapters/index.js.map +1 -0
- package/lib/module/components/OfflineProvider.js +43 -0
- package/lib/module/components/OfflineProvider.js.map +1 -0
- package/lib/module/components/OfflineSyncPrompt.js +31 -0
- package/lib/module/components/OfflineSyncPrompt.js.map +1 -0
- package/lib/module/core/OfflineManager.js +304 -0
- package/lib/module/core/OfflineManager.js.map +1 -0
- package/lib/module/core/StorageAdapter.js +25 -0
- package/lib/module/core/StorageAdapter.js.map +1 -0
- package/lib/module/core/types.js +11 -0
- package/lib/module/core/types.js.map +1 -0
- package/lib/module/global.d.js +2 -0
- package/lib/module/global.d.js.map +1 -0
- package/lib/module/hooks/useOfflineMutation.js +57 -0
- package/lib/module/hooks/useOfflineMutation.js.map +1 -0
- package/lib/module/hooks/useOfflineQueue.js +17 -0
- package/lib/module/hooks/useOfflineQueue.js.map +1 -0
- package/lib/module/hooks/useOfflineSyncInterceptor.js +38 -0
- package/lib/module/hooks/useOfflineSyncInterceptor.js.map +1 -0
- package/lib/module/hooks/useSyncProgress.js +29 -0
- package/lib/module/hooks/useSyncProgress.js.map +1 -0
- package/lib/module/index.js +20 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/adapters/index.d.ts +12 -0
- package/lib/typescript/adapters/index.d.ts.map +1 -0
- package/lib/typescript/components/OfflineProvider.d.ts +13 -0
- package/lib/typescript/components/OfflineProvider.d.ts.map +1 -0
- package/lib/typescript/components/OfflineSyncPrompt.d.ts +11 -0
- package/lib/typescript/components/OfflineSyncPrompt.d.ts.map +1 -0
- package/lib/typescript/core/OfflineManager.d.ts +53 -0
- package/lib/typescript/core/OfflineManager.d.ts.map +1 -0
- package/lib/typescript/core/StorageAdapter.d.ts +21 -0
- package/lib/typescript/core/StorageAdapter.d.ts.map +1 -0
- package/lib/typescript/core/types.d.ts +23 -0
- package/lib/typescript/core/types.d.ts.map +1 -0
- package/lib/typescript/hooks/useOfflineMutation.d.ts +8 -0
- package/lib/typescript/hooks/useOfflineMutation.d.ts.map +1 -0
- package/lib/typescript/hooks/useOfflineQueue.d.ts +8 -0
- package/lib/typescript/hooks/useOfflineQueue.d.ts.map +1 -0
- package/lib/typescript/hooks/useOfflineSyncInterceptor.d.ts +9 -0
- package/lib/typescript/hooks/useOfflineSyncInterceptor.d.ts.map +1 -0
- package/lib/typescript/hooks/useSyncProgress.d.ts +23 -0
- package/lib/typescript/hooks/useSyncProgress.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +11 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/package.json +73 -0
- package/src/adapters/index.ts +141 -0
- package/src/components/OfflineProvider.tsx +52 -0
- package/src/components/OfflineSyncPrompt.tsx +32 -0
- package/src/core/OfflineManager.ts +338 -0
- package/src/core/StorageAdapter.ts +42 -0
- package/src/core/types.ts +33 -0
- package/src/global.d.ts +1 -0
- package/src/hooks/useOfflineMutation.ts +63 -0
- package/src/hooks/useOfflineQueue.ts +17 -0
- package/src/hooks/useOfflineSyncInterceptor.ts +39 -0
- package/src/hooks/useSyncProgress.ts +32 -0
- package/src/index.ts +17 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { StorageAdapter, RecordStorageAdapter } from './StorageAdapter';
|
|
2
|
+
import { MemoryStorageAdapter, isRecordAdapter } from './StorageAdapter';
|
|
3
|
+
import type { OfflineAction, SyncProgress, SyncProgressItem } from './types';
|
|
4
|
+
import { INITIAL_SYNC_PROGRESS } from './types';
|
|
5
|
+
import { getMMKVAdapter, getAsyncStorageAdapter, getRealmAdapter } from '../adapters';
|
|
6
|
+
import type { RealmAdapterOptions } from '../adapters';
|
|
7
|
+
|
|
8
|
+
// Simple UUID string generator
|
|
9
|
+
function generateUUID() {
|
|
10
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
11
|
+
const r = (Math.random() * 16) | 0,
|
|
12
|
+
v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
13
|
+
return v.toString(16);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface OfflineManagerConfig {
|
|
18
|
+
storage?: StorageAdapter | RecordStorageAdapter;
|
|
19
|
+
storageType?: 'mmkv' | 'async-storage' | 'memory' | 'realm';
|
|
20
|
+
storageKey?: string;
|
|
21
|
+
syncMode?: 'auto' | 'manual';
|
|
22
|
+
|
|
23
|
+
// Realm-specific options (only used when storageType is 'realm')
|
|
24
|
+
realmOptions?: RealmAdapterOptions;
|
|
25
|
+
|
|
26
|
+
// The function that handles the actual API call for each queued action
|
|
27
|
+
onSyncAction?: (action: OfflineAction) => Promise<void>;
|
|
28
|
+
|
|
29
|
+
// Called when internet restores AND there are pending items in manual mode.
|
|
30
|
+
onOnlineRestore?: (params: {
|
|
31
|
+
pendingCount: number;
|
|
32
|
+
syncNow: () => Promise<void>;
|
|
33
|
+
discardQueue: () => Promise<void>;
|
|
34
|
+
}) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class OfflineManagerClass {
|
|
38
|
+
private queue: OfflineAction[] = [];
|
|
39
|
+
private storage: StorageAdapter | RecordStorageAdapter = new MemoryStorageAdapter();
|
|
40
|
+
private storageKey: string = 'REACT_NATIVE_OFFLINE_QUEUE_STATE';
|
|
41
|
+
private useRecordAdapter: boolean = false;
|
|
42
|
+
|
|
43
|
+
// Queue listeners (for useOfflineQueue / useSyncExternalStore)
|
|
44
|
+
private queueListeners: Set<() => void> = new Set();
|
|
45
|
+
|
|
46
|
+
// Progress listeners (for useSyncProgress)
|
|
47
|
+
private progressListeners: Set<() => void> = new Set();
|
|
48
|
+
|
|
49
|
+
// Per-action handler registry
|
|
50
|
+
private actionHandlers: Map<string, (payload: any) => Promise<void>> = new Map();
|
|
51
|
+
|
|
52
|
+
public isInitialized = false;
|
|
53
|
+
public syncMode: 'auto' | 'manual' = 'manual';
|
|
54
|
+
public onSyncAction?: (action: OfflineAction) => Promise<void>;
|
|
55
|
+
public onOnlineRestore?: OfflineManagerConfig['onOnlineRestore'];
|
|
56
|
+
public isSyncing = false;
|
|
57
|
+
|
|
58
|
+
// ─── Sync Progress State ───
|
|
59
|
+
private _syncProgress: SyncProgress = { ...INITIAL_SYNC_PROGRESS };
|
|
60
|
+
|
|
61
|
+
public get syncProgress(): SyncProgress {
|
|
62
|
+
return this._syncProgress;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Configuration ───
|
|
66
|
+
public async configure(config: OfflineManagerConfig) {
|
|
67
|
+
if (config.storageType) {
|
|
68
|
+
if (config.storageType === 'mmkv') {
|
|
69
|
+
this.storage = getMMKVAdapter();
|
|
70
|
+
this.useRecordAdapter = false;
|
|
71
|
+
} else if (config.storageType === 'async-storage') {
|
|
72
|
+
this.storage = getAsyncStorageAdapter();
|
|
73
|
+
this.useRecordAdapter = false;
|
|
74
|
+
} else if (config.storageType === 'realm') {
|
|
75
|
+
this.storage = getRealmAdapter(config.realmOptions);
|
|
76
|
+
this.useRecordAdapter = true;
|
|
77
|
+
} else {
|
|
78
|
+
this.storage = new MemoryStorageAdapter();
|
|
79
|
+
this.useRecordAdapter = false;
|
|
80
|
+
}
|
|
81
|
+
} else if (config.storage) {
|
|
82
|
+
this.storage = config.storage;
|
|
83
|
+
this.useRecordAdapter = isRecordAdapter(config.storage);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (config.storageKey) {
|
|
87
|
+
this.storageKey = config.storageKey;
|
|
88
|
+
}
|
|
89
|
+
if (config.syncMode) {
|
|
90
|
+
this.syncMode = config.syncMode;
|
|
91
|
+
}
|
|
92
|
+
if (config.onSyncAction) {
|
|
93
|
+
this.onSyncAction = config.onSyncAction;
|
|
94
|
+
}
|
|
95
|
+
if (config.onOnlineRestore) {
|
|
96
|
+
this.onOnlineRestore = config.onOnlineRestore;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await this.loadQueue();
|
|
100
|
+
this.isInitialized = true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Storage (supports both adapter types) ───
|
|
104
|
+
private async loadQueue() {
|
|
105
|
+
try {
|
|
106
|
+
if (this.useRecordAdapter) {
|
|
107
|
+
const adapter = this.storage as RecordStorageAdapter;
|
|
108
|
+
this.queue = await adapter.getAll();
|
|
109
|
+
} else {
|
|
110
|
+
const adapter = this.storage as StorageAdapter;
|
|
111
|
+
const data = await adapter.getItem(this.storageKey);
|
|
112
|
+
if (data) {
|
|
113
|
+
this.queue = JSON.parse(data) || [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
this.notifyQueueListeners();
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.warn('[OfflineManager] Failed to load queue from storage', e);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async saveQueue() {
|
|
123
|
+
try {
|
|
124
|
+
if (!this.useRecordAdapter) {
|
|
125
|
+
const adapter = this.storage as StorageAdapter;
|
|
126
|
+
await adapter.setItem(this.storageKey, JSON.stringify(this.queue));
|
|
127
|
+
}
|
|
128
|
+
// Record adapter saves per-operation, no need for full queue write
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.warn('[OfflineManager] Failed to save queue to storage', e);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Queue Subscriptions (useSyncExternalStore) ───
|
|
135
|
+
public subscribeQueue = (listener: () => void): (() => void) => {
|
|
136
|
+
this.queueListeners.add(listener);
|
|
137
|
+
return () => this.queueListeners.delete(listener);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Backward compatibility alias
|
|
141
|
+
public subscribe = this.subscribeQueue;
|
|
142
|
+
|
|
143
|
+
private notifyQueueListeners() {
|
|
144
|
+
this.queueListeners.forEach((l) => l());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Progress Subscriptions (useSyncExternalStore) ───
|
|
148
|
+
public subscribeProgress = (listener: () => void): (() => void) => {
|
|
149
|
+
this.progressListeners.add(listener);
|
|
150
|
+
return () => this.progressListeners.delete(listener);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
private updateProgress(partial: Partial<SyncProgress>) {
|
|
154
|
+
this._syncProgress = { ...this._syncProgress, ...partial };
|
|
155
|
+
this.progressListeners.forEach((l) => l());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private updateProgressItem(id: string, update: Partial<SyncProgressItem>) {
|
|
159
|
+
this._syncProgress = {
|
|
160
|
+
...this._syncProgress,
|
|
161
|
+
items: this._syncProgress.items.map((item) =>
|
|
162
|
+
item.action.id === id ? { ...item, ...update } : item
|
|
163
|
+
),
|
|
164
|
+
};
|
|
165
|
+
this.progressListeners.forEach((l) => l());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Queue CRUD ───
|
|
169
|
+
public async push<T>(actionName: string, payload: T): Promise<OfflineAction<T>> {
|
|
170
|
+
const action: OfflineAction<T> = {
|
|
171
|
+
id: generateUUID(),
|
|
172
|
+
actionName,
|
|
173
|
+
payload,
|
|
174
|
+
createdAt: Date.now(),
|
|
175
|
+
retryCount: 0,
|
|
176
|
+
};
|
|
177
|
+
this.queue = [...this.queue, action];
|
|
178
|
+
|
|
179
|
+
if (this.useRecordAdapter) {
|
|
180
|
+
await (this.storage as RecordStorageAdapter).insert(action as OfflineAction);
|
|
181
|
+
} else {
|
|
182
|
+
await this.saveQueue();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.notifyQueueListeners();
|
|
186
|
+
return action;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
public async remove(id: string): Promise<void> {
|
|
190
|
+
this.queue = this.queue.filter((a) => a.id !== id);
|
|
191
|
+
|
|
192
|
+
if (this.useRecordAdapter) {
|
|
193
|
+
await (this.storage as RecordStorageAdapter).remove(id);
|
|
194
|
+
} else {
|
|
195
|
+
await this.saveQueue();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.notifyQueueListeners();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
public async clear(): Promise<void> {
|
|
202
|
+
this.queue = [];
|
|
203
|
+
|
|
204
|
+
if (this.useRecordAdapter) {
|
|
205
|
+
await (this.storage as RecordStorageAdapter).clear();
|
|
206
|
+
} else {
|
|
207
|
+
await this.saveQueue();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.notifyQueueListeners();
|
|
211
|
+
this.updateProgress({ ...INITIAL_SYNC_PROGRESS });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
public getQueue(): OfflineAction[] {
|
|
215
|
+
return this.queue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ─── Per-Action Handler Registry ───
|
|
219
|
+
public registerHandler(actionName: string, handler: (payload: any) => Promise<void>) {
|
|
220
|
+
this.actionHandlers.set(actionName, handler);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
public unregisterHandler(actionName: string) {
|
|
224
|
+
this.actionHandlers.delete(actionName);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
public getHandler(actionName: string): ((payload: any) => Promise<void>) | undefined {
|
|
228
|
+
return this.actionHandlers.get(actionName);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ─── The Central Sync Mechanism (with progress tracking) ───
|
|
232
|
+
public async flushQueue(): Promise<void> {
|
|
233
|
+
if (this.queue.length === 0 || this.isSyncing) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const hasAnyHandler = this.onSyncAction || this.actionHandlers.size > 0;
|
|
238
|
+
if (!hasAnyHandler) {
|
|
239
|
+
console.warn('[OfflineManager] No handlers registered and no onSyncAction configured.');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
this.isSyncing = true;
|
|
244
|
+
this.notifyQueueListeners();
|
|
245
|
+
|
|
246
|
+
const currentQueue = [...this.queue];
|
|
247
|
+
|
|
248
|
+
// Initialize progress tracking for this sync session
|
|
249
|
+
const progressItems: SyncProgressItem[] = currentQueue.map((action) => ({
|
|
250
|
+
action,
|
|
251
|
+
status: 'pending' as const,
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
this.updateProgress({
|
|
255
|
+
isActive: true,
|
|
256
|
+
totalCount: currentQueue.length,
|
|
257
|
+
completedCount: 0,
|
|
258
|
+
failedCount: 0,
|
|
259
|
+
currentAction: null,
|
|
260
|
+
items: progressItems,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
let completedCount = 0;
|
|
264
|
+
let failedCount = 0;
|
|
265
|
+
|
|
266
|
+
for (const action of currentQueue) {
|
|
267
|
+
// Mark current item as "syncing"
|
|
268
|
+
this.updateProgress({ currentAction: action });
|
|
269
|
+
this.updateProgressItem(action.id, { status: 'syncing' });
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
// Per-action handler takes priority, then fallback to onSyncAction
|
|
273
|
+
const handler = this.actionHandlers.get(action.actionName);
|
|
274
|
+
if (handler) {
|
|
275
|
+
await handler(action.payload);
|
|
276
|
+
} else if (this.onSyncAction) {
|
|
277
|
+
await this.onSyncAction(action);
|
|
278
|
+
} else {
|
|
279
|
+
throw new Error(`No handler registered for action: ${action.actionName}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Success → remove from queue + mark in progress
|
|
283
|
+
await this.remove(action.id);
|
|
284
|
+
completedCount++;
|
|
285
|
+
this.updateProgressItem(action.id, { status: 'success' });
|
|
286
|
+
this.updateProgress({ completedCount });
|
|
287
|
+
} catch (error: any) {
|
|
288
|
+
console.warn(`[OfflineManager] Action ${action.actionName} failed to sync.`, error);
|
|
289
|
+
|
|
290
|
+
// Mark as failed in progress
|
|
291
|
+
failedCount++;
|
|
292
|
+
this.updateProgressItem(action.id, {
|
|
293
|
+
status: 'failed',
|
|
294
|
+
error: error?.message || 'Unknown error',
|
|
295
|
+
});
|
|
296
|
+
this.updateProgress({ failedCount });
|
|
297
|
+
|
|
298
|
+
// Update retry count on the queue item
|
|
299
|
+
const target = this.queue.find((a) => a.id === action.id);
|
|
300
|
+
if (target) {
|
|
301
|
+
target.retryCount += 1;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (this.useRecordAdapter) {
|
|
305
|
+
await (this.storage as RecordStorageAdapter).update(action.id, {
|
|
306
|
+
retryCount: (target?.retryCount || 0),
|
|
307
|
+
});
|
|
308
|
+
} else {
|
|
309
|
+
await this.saveQueue();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.notifyQueueListeners();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.isSyncing = false;
|
|
317
|
+
this.updateProgress({ isActive: false, currentAction: null });
|
|
318
|
+
this.notifyQueueListeners();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Called by OfflineProvider when internet restores ───
|
|
322
|
+
public handleOnlineRestore() {
|
|
323
|
+
if (this.queue.length === 0) return;
|
|
324
|
+
|
|
325
|
+
if (this.syncMode === 'auto') {
|
|
326
|
+
this.flushQueue();
|
|
327
|
+
} else if (this.syncMode === 'manual' && this.onOnlineRestore) {
|
|
328
|
+
this.onOnlineRestore({
|
|
329
|
+
pendingCount: this.queue.length,
|
|
330
|
+
syncNow: () => this.flushQueue(),
|
|
331
|
+
discardQueue: () => this.clear(),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// If manual + no onOnlineRestore → silent (nothing happens)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export const OfflineManager = new OfflineManagerClass();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { OfflineAction } from './types';
|
|
2
|
+
|
|
3
|
+
// Key-Value adapter (MMKV, AsyncStorage, Memory)
|
|
4
|
+
// Stores entire queue as a single JSON string
|
|
5
|
+
export interface StorageAdapter {
|
|
6
|
+
getItem: (key: string) => Promise<string | null> | string | null;
|
|
7
|
+
setItem: (key: string, value: string) => Promise<void> | void;
|
|
8
|
+
removeItem: (key: string) => Promise<void> | void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Record-based adapter (Realm, SQLite, WatermelonDB)
|
|
12
|
+
// Each queue item is a separate database record — much faster for large queues
|
|
13
|
+
export interface RecordStorageAdapter {
|
|
14
|
+
insert: (action: OfflineAction) => Promise<void> | void;
|
|
15
|
+
remove: (id: string) => Promise<void> | void;
|
|
16
|
+
getAll: () => Promise<OfflineAction[]> | OfflineAction[];
|
|
17
|
+
clear: () => Promise<void> | void;
|
|
18
|
+
update: (id: string, partial: Partial<OfflineAction>) => Promise<void> | void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Type guard to check which adapter type is being used
|
|
22
|
+
export function isRecordAdapter(
|
|
23
|
+
adapter: StorageAdapter | RecordStorageAdapter
|
|
24
|
+
): adapter is RecordStorageAdapter {
|
|
25
|
+
return 'insert' in adapter && 'getAll' in adapter;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class MemoryStorageAdapter implements StorageAdapter {
|
|
29
|
+
private store: Record<string, string> = {};
|
|
30
|
+
|
|
31
|
+
getItem(key: string): string | null {
|
|
32
|
+
return this.store[key] || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setItem(key: string, value: string): void {
|
|
36
|
+
this.store[key] = value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
removeItem(key: string): void {
|
|
40
|
+
delete this.store[key];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface OfflineAction<TPayload = any> {
|
|
2
|
+
id: string; // Unique identifier for the action
|
|
3
|
+
actionName: string; // The developer-defined name of the action
|
|
4
|
+
payload: TPayload; // The generic payload (e.g. data to POST)
|
|
5
|
+
createdAt: number; // Timestamp of when the action was queued
|
|
6
|
+
retryCount: number; // Number of failed attempts
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type SyncItemStatus = 'pending' | 'syncing' | 'success' | 'failed';
|
|
10
|
+
|
|
11
|
+
export interface SyncProgressItem {
|
|
12
|
+
action: OfflineAction;
|
|
13
|
+
status: SyncItemStatus;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SyncProgress {
|
|
18
|
+
isActive: boolean; // Is a sync session currently running?
|
|
19
|
+
totalCount: number; // Total items when sync started
|
|
20
|
+
completedCount: number; // Successfully synced count
|
|
21
|
+
failedCount: number; // Failed items count
|
|
22
|
+
currentAction: OfflineAction | null; // Item currently being synced
|
|
23
|
+
items: SyncProgressItem[]; // Full progress of every item in this session
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const INITIAL_SYNC_PROGRESS: SyncProgress = {
|
|
27
|
+
isActive: false,
|
|
28
|
+
totalCount: 0,
|
|
29
|
+
completedCount: 0,
|
|
30
|
+
failedCount: 0,
|
|
31
|
+
currentAction: null,
|
|
32
|
+
items: [],
|
|
33
|
+
};
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare const __DEV__: boolean;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { OfflineManager } from '../core/OfflineManager';
|
|
3
|
+
import { useNetworkStatus } from '../components/OfflineProvider';
|
|
4
|
+
|
|
5
|
+
export function useOfflineMutation<TPayload>(
|
|
6
|
+
actionName: string,
|
|
7
|
+
options?: {
|
|
8
|
+
handler?: (payload: TPayload) => Promise<void>;
|
|
9
|
+
onOptimisticSuccess?: (payload: TPayload) => void;
|
|
10
|
+
onError?: (error: Error, payload: TPayload) => void;
|
|
11
|
+
}
|
|
12
|
+
) {
|
|
13
|
+
const { isOnline } = useNetworkStatus();
|
|
14
|
+
const handlerRef = useRef(options?.handler);
|
|
15
|
+
handlerRef.current = options?.handler;
|
|
16
|
+
|
|
17
|
+
// Register per-action handler (persists even after unmount)
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (handlerRef.current) {
|
|
20
|
+
OfflineManager.registerHandler(actionName, (payload: any) =>
|
|
21
|
+
handlerRef.current!(payload)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}, [actionName]);
|
|
25
|
+
|
|
26
|
+
const mutateOffline = async (payload: TPayload) => {
|
|
27
|
+
// Resolve which handler to use: per-action handler > global onSyncAction
|
|
28
|
+
const handler = handlerRef.current || OfflineManager.getHandler(actionName);
|
|
29
|
+
const globalHandler = OfflineManager.onSyncAction;
|
|
30
|
+
const hasHandler = handler || globalHandler;
|
|
31
|
+
|
|
32
|
+
if (isOnline && hasHandler) {
|
|
33
|
+
// ── ONLINE: Execute directly, skip the queue ──
|
|
34
|
+
if (__DEV__) console.log(`[OfflineQueue] mutate: ${actionName} (direct)`);
|
|
35
|
+
try {
|
|
36
|
+
if (handler) {
|
|
37
|
+
await handler(payload);
|
|
38
|
+
} else if (globalHandler) {
|
|
39
|
+
await globalHandler({
|
|
40
|
+
id: '',
|
|
41
|
+
actionName,
|
|
42
|
+
payload,
|
|
43
|
+
createdAt: Date.now(),
|
|
44
|
+
retryCount: 0,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
options?.onOptimisticSuccess?.(payload);
|
|
48
|
+
} catch (error: any) {
|
|
49
|
+
console.warn(`[OfflineQueue] mutate: ${actionName} failed, falling back to queue`, error);
|
|
50
|
+
await OfflineManager.push(actionName, payload);
|
|
51
|
+
options?.onOptimisticSuccess?.(payload);
|
|
52
|
+
options?.onError?.(error, payload);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
// ── OFFLINE: Add to queue + optimistic update ──
|
|
56
|
+
if (__DEV__) console.log(`[OfflineQueue] mutate: ${actionName} (queued)`);
|
|
57
|
+
await OfflineManager.push(actionName, payload);
|
|
58
|
+
options?.onOptimisticSuccess?.(payload);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return { mutateOffline };
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { OfflineManager } from '../core/OfflineManager';
|
|
3
|
+
|
|
4
|
+
const subscribe = (listener: () => void) => OfflineManager.subscribe(listener);
|
|
5
|
+
const getSnapshot = () => OfflineManager.getQueue();
|
|
6
|
+
|
|
7
|
+
export function useOfflineQueue() {
|
|
8
|
+
const queue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
queue,
|
|
12
|
+
pendingCount: queue.length,
|
|
13
|
+
isSyncing: OfflineManager.isSyncing,
|
|
14
|
+
syncNow: () => OfflineManager.flushQueue(),
|
|
15
|
+
clearQueue: () => OfflineManager.clear(),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useNetworkStatus } from '../components/OfflineProvider';
|
|
3
|
+
import { useOfflineQueue } from './useOfflineQueue';
|
|
4
|
+
|
|
5
|
+
export interface InterceptorOptions {
|
|
6
|
+
onPromptNeeded: (params: {
|
|
7
|
+
pendingCount: number;
|
|
8
|
+
syncNow: () => Promise<void>;
|
|
9
|
+
discardQueue: () => Promise<void>;
|
|
10
|
+
}) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useOfflineSyncInterceptor({ onPromptNeeded }: InterceptorOptions) {
|
|
14
|
+
const { isOnline } = useNetworkStatus();
|
|
15
|
+
const { pendingCount, syncNow, clearQueue } = useOfflineQueue();
|
|
16
|
+
|
|
17
|
+
const onPromptNeededRef = useRef(onPromptNeeded);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
onPromptNeededRef.current = onPromptNeeded;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const [hasPrompted, setHasPrompted] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (isOnline && pendingCount > 0 && !hasPrompted) {
|
|
26
|
+
setHasPrompted(true);
|
|
27
|
+
onPromptNeededRef.current({
|
|
28
|
+
pendingCount,
|
|
29
|
+
syncNow: async () => {
|
|
30
|
+
await syncNow();
|
|
31
|
+
},
|
|
32
|
+
discardQueue: clearQueue,
|
|
33
|
+
});
|
|
34
|
+
} else if (!isOnline || pendingCount === 0) {
|
|
35
|
+
// Reset prompt state when offline or when queue is cleared
|
|
36
|
+
setHasPrompted(false);
|
|
37
|
+
}
|
|
38
|
+
}, [isOnline, pendingCount, hasPrompted, syncNow, clearQueue]);
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { OfflineManager } from '../core/OfflineManager';
|
|
3
|
+
|
|
4
|
+
const subscribeProgress = (listener: () => void) => OfflineManager.subscribeProgress(listener);
|
|
5
|
+
const getProgressSnapshot = () => OfflineManager.syncProgress;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Live sync progress tracker.
|
|
9
|
+
* Use this inside a BottomSheet, Modal, or any UI to show per-item sync status.
|
|
10
|
+
*
|
|
11
|
+
* Returns:
|
|
12
|
+
* - isActive: whether a sync session is currently running
|
|
13
|
+
* - totalCount: total items in this sync batch
|
|
14
|
+
* - completedCount: successfully synced so far
|
|
15
|
+
* - failedCount: items that failed
|
|
16
|
+
* - currentAction: the action currently being synced
|
|
17
|
+
* - items: full list with per-item status (pending | syncing | success | failed)
|
|
18
|
+
* - percentage: 0-100 completion percentage
|
|
19
|
+
*/
|
|
20
|
+
export function useSyncProgress() {
|
|
21
|
+
const progress = useSyncExternalStore(subscribeProgress, getProgressSnapshot, getProgressSnapshot);
|
|
22
|
+
|
|
23
|
+
const percentage =
|
|
24
|
+
progress.totalCount > 0
|
|
25
|
+
? Math.round(((progress.completedCount + progress.failedCount) / progress.totalCount) * 100)
|
|
26
|
+
: 0;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
...progress,
|
|
30
|
+
percentage,
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Core
|
|
2
|
+
export * from './core/types';
|
|
3
|
+
export * from './core/StorageAdapter';
|
|
4
|
+
export { OfflineManager, type OfflineManagerConfig } from './core/OfflineManager';
|
|
5
|
+
|
|
6
|
+
// Adapters
|
|
7
|
+
export { getMMKVAdapter, getAsyncStorageAdapter, getRealmAdapter, type RealmAdapterOptions } from './adapters';
|
|
8
|
+
|
|
9
|
+
// Components
|
|
10
|
+
export * from './components/OfflineProvider';
|
|
11
|
+
export * from './components/OfflineSyncPrompt';
|
|
12
|
+
|
|
13
|
+
// Hooks
|
|
14
|
+
export * from './hooks/useOfflineQueue';
|
|
15
|
+
export * from './hooks/useOfflineMutation';
|
|
16
|
+
export * from './hooks/useOfflineSyncInterceptor';
|
|
17
|
+
export * from './hooks/useSyncProgress';
|