expo-offline-sync 0.0.0-stage → 1.0.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/README.md CHANGED
@@ -1,3 +1,291 @@
1
- # Temporary Holding Version
1
+ # expo-offline-sync 🚀
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/expo-offline-sync.svg?style=flat-square)](https://www.npmjs.com/package/expo-offline-sync)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-blue.svg?style=flat-square)](https://www.typescriptlang.org/)
6
+ [![Expo SDK](https://img.shields.io/badge/Expo_SDK-51%20|%2052+-black.svg?style=flat-square)](https://expo.dev/)
7
+ [![Platform](https://img.shields.io/badge/Platforms-iOS%20|%20Android%20|%20Web-lightgrey.svg?style=flat-square)](#)
8
+
9
+ > **Deterministic, scoped, engine-agnostic offline mutation & request queue for Expo & React Native.**
10
+ > Add offline-first resilience to your existing REST, GraphQL, or tRPC endpoints in **under 10 minutes** without rewriting your database.
11
+
12
+ ---
13
+
14
+ ## 🌟 Why expo-offline-sync?
15
+
16
+ Existing offline solutions force extreme trade-offs:
17
+
18
+ - **Heavyweight / Invasive (WatermelonDB, RxDB, PowerSync):** Require rewriting your entire schema with custom ORMs, setting up specialized sync backends, or paying for proprietary cloud sync engines.
19
+ - **Legacy & Abandoned (redux-offline):** Bound to Redux, broken in modern Expo / React Native New Architecture.
20
+ - **TanStack Query Mutation Limitations:** Storing pending mutations across app restarts is notoriously tricky and fails when functions/closures cannot be serialized.
21
+
22
+ **`expo-offline-sync` takes the pragmatic approach:**
23
+ Keep your existing backend and state management (Zustand, TanStack Query, Redux, or raw fetch). Whenever the user performs an action while offline (e.g. sending a message, submitting a form, liking a post), it safely queues it into **encrypted/durable local SQLite** and synchronizes deterministically when connectivity returns.
24
+
25
+ ---
26
+
27
+ ## ✨ Features
28
+
29
+ - 🎯 **Engine-Agnostic:** Works with fetch, Axios, TanStack Query, Zustand, tRPC, GraphQL, or Firebase.
30
+ - 🛡 **Scoped Queuing (No Head-of-Line Blocking):** Mutations can run in independent scopes (e.g. `scope: 'chat:room_1'`). A retry in Room 1 will **never** block a profile update in another scope.
31
+ - 🔁 **Jittered Exponential Backoff:** Smart retry policies prevent server thundering herds.
32
+ - 🔐 **Dynamic Auth Token Injection:** Injects fresh JWT/Bearer tokens at execution time (`getHeaders`). Enqueued mutations won't fail with 401 even after being offline for hours!
33
+ - 💾 **Durable SQLite Storage:** Backed by modern Expo SQLite (SDK 51/52+ fast asynchronous API) with in-memory fallbacks.
34
+ - 🎨 **Optimistic UI & Rollback:** Built-in `useOfflineMutation` hook gives instant UI feedback and clean rollback on permanent failure.
35
+ - 🌙 **Background Sync Support:** Optional plugin for `expo-background-fetch` and `expo-task-manager` to sync while the app is in the background or killed.
36
+ - 🧩 **100% Customizable:** Custom storage adapters, custom retry rules, custom network checks, and request interceptors.
37
+
38
+ ---
39
+
40
+ ## 📦 Installation
41
+
42
+ ```bash
43
+ npx expo install expo-offline-sync expo-sqlite @react-native-community/netinfo
44
+ ```
45
+
46
+ _(Optional: For background sync capabilities)_
47
+
48
+ ```bash
49
+ npx expo install expo-background-fetch expo-task-manager
50
+ ```
51
+
52
+ ---
53
+
54
+ ## 🚀 Quick Start (5 Minutes)
55
+
56
+ ### 1. Initialize the Provider
57
+
58
+ Wrap your app with `OfflineSyncProvider`:
59
+
60
+ ```tsx
61
+ // App.tsx
62
+ import React from "react";
63
+ import { OfflineSyncProvider } from "expo-offline-sync";
64
+
65
+ export default function App() {
66
+ return (
67
+ <OfflineSyncProvider
68
+ options={{
69
+ // Dynamically resolve auth token before replaying requests
70
+ getHeaders: async () => ({
71
+ Authorization: `Bearer ${await getAuthToken()}`,
72
+ }),
73
+ }}
74
+ >
75
+ <MainScreen />
76
+ </OfflineSyncProvider>
77
+ );
78
+ }
79
+ ```
80
+
81
+ ---
82
+
83
+ ### 2. Register Mutations (Typed Registry Pattern)
84
+
85
+ Register handlers anywhere during app initialization (e.g. `sync.ts` or inside your API layer):
86
+
87
+ ```typescript
88
+ import { OfflineSyncClient } from "expo-offline-sync";
89
+
90
+ export const syncClient = new OfflineSyncClient();
91
+
92
+ // Register a mutation handler
93
+ syncClient.register(
94
+ "SEND_MESSAGE",
95
+ async (payload: { chatId: string; text: string }) => {
96
+ const response = await fetch(
97
+ `https://api.example.com/chats/${payload.chatId}/messages`,
98
+ {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({ text: payload.text }),
102
+ },
103
+ );
104
+
105
+ if (!response.ok) throw new Error("Failed to send message");
106
+ return response.json();
107
+ },
108
+ );
109
+ ```
110
+
111
+ ---
112
+
113
+ ### 3. Use in React (WhatsApp-Style Chat Example)
114
+
115
+ ```tsx
116
+ import React, { useState } from "react";
117
+ import { View, Text, TextInput, Button } from "react-native";
118
+ import { useOfflineMutation, useQueueStatus } from "expo-offline-sync";
119
+
120
+ export function ChatScreen({ chatId }: { chatId: string }) {
121
+ const [text, setText] = useState("");
122
+ const [messages, setMessages] = useState<any[]>([]);
123
+
124
+ // Hook automatically manages optimistic updates & rollback
125
+ const { mutate } = useOfflineMutation({
126
+ type: "SEND_MESSAGE",
127
+ scope: `chat:${chatId}`, // Scoped FIFO queue for this chat room
128
+ onMutate: (payload, item) => {
129
+ // 1. Instant Optimistic Update
130
+ setMessages((prev) => [
131
+ ...prev,
132
+ { id: item.id, text: payload.text, status: "pending" }, // Shows clock icon 🕒
133
+ ]);
134
+ },
135
+ onSuccess: (result, item) => {
136
+ // 2. Mark as delivered ✓✓
137
+ setMessages((prev) =>
138
+ prev.map((m) => (m.id === item.id ? { ...m, status: "sent" } : m)),
139
+ );
140
+ },
141
+ onError: (error, item) => {
142
+ // 3. Mark as failed ⚠️
143
+ setMessages((prev) =>
144
+ prev.map((m) => (m.id === item.id ? { ...m, status: "failed" } : m)),
145
+ );
146
+ },
147
+ });
148
+
149
+ const handleSend = () => {
150
+ mutate({ chatId, text });
151
+ setText("");
152
+ };
153
+
154
+ return (
155
+ <View>
156
+ {messages.map((m) => (
157
+ <Text key={m.id}>
158
+ {m.text} {m.status === "pending" ? "🕒" : "✓✓"}
159
+ </Text>
160
+ ))}
161
+ <TextInput value={text} onChangeText={setText} />
162
+ <Button title="Send" onPress={handleSend} />
163
+ </View>
164
+ );
165
+ }
166
+ ```
167
+
168
+ ---
169
+
170
+ ### 4. Zero-Boilerplate Direct REST Requests (`enqueueHttp`)
171
+
172
+ Don't want to register a mutation handler? Queue any REST request directly:
173
+
174
+ ```typescript
175
+ await syncClient.enqueueHttp({
176
+ url: "https://api.example.com/v1/posts/like",
177
+ method: "POST",
178
+ body: { postId: "123" },
179
+ scope: "likes", // runs sequentially for likes
180
+ });
181
+ ```
182
+
183
+ ---
184
+
185
+ ## 🎛 Queue Monitoring & Status (`useQueueStatus`)
186
+
187
+ Track real-time queue counts and trigger manual syncs:
188
+
189
+ ```tsx
190
+ import { useQueueStatus } from "expo-offline-sync";
191
+
192
+ export function QueueHeader() {
193
+ const { isOnline, isSyncing, stats, sync, retryAll } = useQueueStatus();
194
+
195
+ return (
196
+ <View>
197
+ <Text>{isOnline ? "🟢 Connected" : "🔴 Offline"}</Text>
198
+ <Text>Pending in SQLite: {stats.pending}</Text>
199
+ <Text>Failed items: {stats.failed}</Text>
200
+
201
+ <Button title="Sync Now" onPress={() => sync()} disabled={isSyncing} />
202
+ {stats.failed > 0 && (
203
+ <Button title="Retry Failed" onPress={() => retryAll()} />
204
+ )}
205
+ </View>
206
+ );
207
+ }
208
+ ```
209
+
210
+ ---
211
+
212
+ ## 🌙 Background Sync (Optional)
213
+
214
+ Replay pending requests even if the user closed the app:
215
+
216
+ ```typescript
217
+ import { registerBackgroundSync } from "expo-offline-sync";
218
+
219
+ // Call in root index.js / App.tsx
220
+ await registerBackgroundSync(syncClient, {
221
+ minimumInterval: 15 * 60, // 15 minutes (iOS minimum)
222
+ startOnBoot: true,
223
+ });
224
+ ```
225
+
226
+ ---
227
+
228
+ ## ⚙️ Advanced Customization
229
+
230
+ Every component of `expo-offline-sync` can be customized:
231
+
232
+ ```typescript
233
+ const client = new OfflineSyncClient({
234
+ // 1. Custom Storage Adapter (defaults to SQLiteAdapter)
235
+ storage: new SQLiteAdapter({ databaseName: "my_app_offline.db" }),
236
+
237
+ // 2. Custom Retry Policy
238
+ retryPolicy: {
239
+ maxRetries: 5,
240
+ initialDelayMs: 1000,
241
+ maxDelayMs: 30000,
242
+ backoffFactor: 2,
243
+ jitter: true,
244
+ shouldRetry: (error, item) => {
245
+ // Don't retry 400 or 422 errors; retry 5xx and network errors
246
+ if (
247
+ error?.status >= 400 &&
248
+ error?.status < 500 &&
249
+ error?.status !== 401
250
+ ) {
251
+ return false;
252
+ }
253
+ return true;
254
+ },
255
+ },
256
+
257
+ // 3. Concurrency
258
+ maxConcurrentScopes: 4, // Max different scopes executing in parallel
259
+
260
+ // 4. Global Request Lifecycle
261
+ onRequest: async (item) => {
262
+ console.log(`Executing mutation ${item.id} of type ${item.type}`);
263
+ },
264
+ onResponse: async (item, result) => {
265
+ console.log(`Mutation ${item.id} completed successfully`);
266
+ },
267
+ onRollback: async (item, error) => {
268
+ console.warn(`Mutation ${item.id} permanently failed:`, error.message);
269
+ },
270
+ });
271
+ ```
272
+
273
+ ---
274
+
275
+ ## 📊 Comparison
276
+
277
+ | Feature | `expo-offline-sync` | WatermelonDB | TanStack Query | redux-offline |
278
+ | :------------------------------- | :-----------------: | :--------------------: | :----------------: | :-------------: |
279
+ | **Setup Time** | **< 10 mins** | Days / Weeks | ~1 hour | ~2 hours |
280
+ | **Backend Changes** | **Zero** | Required Sync Protocol | Zero | Zero |
281
+ | **Works with Any State Manager** | ✅ | ❌ (Own ORM) | ❌ (TanStack only) | ❌ (Redux only) |
282
+ | **Survives App Restarts** | ✅ (Durable SQLite) | ✅ | ⚠️ Flaky Closures | ✅ |
283
+ | **Scoped FIFO (No Blockings)** | ✅ | ❌ | ❌ | ❌ |
284
+ | **Dynamic Token Refresh** | ✅ | ❌ | Custom code | ❌ |
285
+ | **Expo SDK 51/52+ Ready** | ✅ | Needs config | ✅ | ❌ Broken |
286
+
287
+ ---
288
+
289
+ ## 📄 License
290
+
291
+ MIT © [mamicrose](https://github.com/mamicrose/expo-offline-sync/blob/main/LICENSE)