@omnistreamai/data-core
Version:
189 lines (151 loc) • 5.45 kB
Markdown
---
name: offline-sync
description: >
Handle offline sync failures. Use PendingQueue for failed operations, configure
RetryPolicy with exponential backoff, call getPendingCount and syncPending to
manage retry. Understand which errors are retryable (429, 5xx) vs non-retryable (405).
Use onSyncFailed and onSyncSuccess callbacks.
type: core
library: '@omnistreamai/data-sync'
library_version: '0.4.1'
sources:
- 'omni-stream-ai/data-sync:packages/core/src/types.ts'
- 'omni-stream-ai/data-sync:packages/core/src/pending-queue.ts'
- 'omni-stream-ai/data-sync:packages/core/src/store.ts'
---
# Offline Sync
Handle network failures and sync retries when operations fail.
## Setup
```typescript
import { createSyncManager } from '@omnistreamai/data-core';
import { DEFAULT_RETRY_POLICY } from '@omnistreamai/data-core';
const syncManager = createSyncManager({
localAdapter: new IndexedDBAdapter({ dbName: 'app', version: 1 }),
remoteAdapter: new WebDAVAdapter(),
notThrowLocalError: true, // Required for sync callbacks to work
});
const store = syncManager.createStore<Todo>('todos', {
idKey: 'id',
syncConfig: {
defaultPolicy: {
maxRetries: 3,
baseDelayMs: 5000,
backoff: 'exponential',
jitter: true,
retryableStatuses: [408, 429, 500, 502, 503, 504],
},
autoSync: false, // Manual sync only
},
syncCallbacks: {
onSyncFailed: ({ item, error, attempt, isMaxRetriesExceeded, nextRetryDelayMs }) => {
console.error(`Sync failed for ${item.recordId}: ${error.message}`, {
attempt,
isMaxRetriesExceeded,
nextRetryDelayMs,
});
},
onSyncSuccess: (item) => {
console.log(`Synced ${item.recordId} (${item.operation})`);
},
},
});
```
## Core Patterns
### Check pending sync count
```typescript
const pendingCount = await store.getPendingCount();
console.log(`${pendingCount} operations waiting to sync`);
```
### Manually sync pending items
```typescript
// Call after coming back online
const result = await store.syncPending();
console.log(`Synced: ${result.success}, Failed: ${result.failed}`);
// syncPending returns items that are past their retry delay
// Retries follow exponential backoff: attempt 1 = immediate, attempt 2+ = baseDelay * 2^(n-1)
```
### Understand retryable vs non-retryable errors
```typescript
// These errors ARE queued for retry:
// - 408 Request Timeout
// - 429 Too Many Requests
// - 500, 502, 503, 504 Server errors
// - ECONNREFUSED, ENOTFOUND, ETIMEDOUT, 'timeout', 'network', 'fetch failed'
// This error is NEVER queued:
// - 405 Method Not Allowed — operation not supported by server, retrying won't help
```
### Configure retry policy
```typescript
const store = syncManager.createStore<Todo>('todos', {
idKey: 'id',
syncConfig: {
defaultPolicy: {
maxRetries: 5,
baseDelayMs: 10000, // 10 seconds base
maxDelayMs: 300000, // 5 minutes max
backoff: 'exponential', // 10s → 20s → 40s → 80s → 160s
jitter: true, // Random 0-50% added to prevent thundering herd
retryableStatuses: [429, 500, 502, 503, 504],
retryableErrors: ['ECONNREFUSED', 'ETIMEDOUT', 'network'],
},
},
});
```
### Retry flow behavior
```typescript
// When add/update/delete fails with retryable error:
// 1. Item added to pending queue with attempts=1
// 2. onSyncFailed called with isMaxRetriesExceeded=false
// 3. When syncPending() is called and delay has passed:
// - If attempts < maxRetries: retry operation, attempts++
// - If attempts >= maxRetries: mark failed, onSyncFailed with isMaxRetriesExceeded=true
```
## Common Mistakes
### HIGH Assume all errors are retried
Wrong:
```typescript
// Assuming every failed operation will be retried automatically
await store.add({ id: '1', title: 'Test' });
// If this fails with 405, it might be lost — 405 is NOT queued
```
Correct:
```typescript
// 405 Method Not Allowed is NOT queued — understand which errors retry
// 405 means the server doesn't support this operation — no point retrying
// Only these are queued:
// - 429 Too Many Requests — wait and retry
// - 500/502/503/504 — server issue, retry may succeed
// Always monitor with onSyncFailed callback:
syncCallbacks: {
onSyncFailed: ({ item, isMaxRetriesExceeded }) => {
if (isMaxRetriesExceeded) {
// Item permanently failed — notify user or persist to dead-letter queue
console.error(`Permanent sync failure for ${item.recordId}: ${item.lastError}`);
}
},
},
```
Source: packages/core/src/pending-queue.ts:44-58
---
### HIGH Not using notThrowLocalError with syncCallbacks
Wrong:
```typescript
const syncManager = createSyncManager({
localAdapter: new IndexedDBAdapter(),
remoteAdapter: new WebDAVAdapter(),
// notThrowLocalError defaults to false
// When a local write succeeds but remote fails, error propagates
// syncCallbacks.onSyncFailed will NOT fire
});
```
Correct:
```typescript
const syncManager = createSyncManager({
localAdapter: new IndexedDBAdapter(),
remoteAdapter: new WebDAVAdapter(),
notThrowLocalError: true, // ✅ Required for sync callbacks to work
});
```
`notThrowLocalError` must be `true` for `onSyncFailed` and `onSyncSuccess` callbacks to fire. When `false`, local write errors propagate and sync callbacks are skipped.
Source: packages/core/src/store.ts:76-83
See also: data-sync/initialize-sync-system