Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/offline-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Offline-First Subscription Sync Architecture

SubTrackr uses an offline-first data synchronization model to ensure the app remains fully functional (read and write) when network connectivity is lost, with automatic background synchronization and deterministic conflict resolution on network recovery.

## Core Components

```mermaid
graph TD
A[UI Components] --> B[useOfflineSync Hook]
B --> C[useSubscriptionStore]
C --> D[SubscriptionCRDT Engine]
B --> E[NetworkMonitor]
C --> F[AsyncStorage Local Cache]
C --> G[API Sync Endpoint]
```

### 1. NetworkMonitor (`src/services/network/networkMonitor.ts`)
Uses `@react-native-community/netinfo` to actively monitor connectivity. Provides:
- Synchronous query of online state (`isOnline()`).
- Event listeners notifying subscribers when connection status changes.

### 2. SubscriptionCRDT (`src/services/cache/crdt.ts`)
Implements a state-based CRDT merging strategy using:
- **Last-Write-Wins-Register (LWW-Register)**: Every field of a subscription has an associated modification epoch timestamp. During a merge, the higher timestamp wins.
- **LWW-Element-Set**: Deletions are tracked using a `deletedAt` tombstone timestamp. If a subscription has `deletedAt >= max(timestamps)`, it is considered deleted.
This satisfies commutative, associative, and idempotent mathematical properties, ensuring all devices converge to the exact same state regardless of sync order or failure retries.

### 3. Subscription Store (`src/store/subscriptionStore.ts`)
Manages Zustand state integrating CRDT metadata and persistence.
- Persists both subscriptions and their CRDT metadata locally via `AsyncStorage` debounced writes.
- Tracks `syncStatus`: `'idle' | 'pending' | 'syncing' | 'conflict' | 'error'`.
- Intercepts mutations (`addSubscription`, `updateSubscription`, `deleteSubscription`, `toggleSubscriptionStatus`) to:
1. Modify local data instantly.
2. Set field timestamps in `crdtMetadata`.
3. Mark `syncStatus` as `pending`.
4. Trigger background sync immediately if online.

### 4. useOfflineSync Hook (`src/hooks/useOfflineSync.ts`)
- Subscribes to `networkMonitor`.
- Automatically triggers a store sync when network transitions from offline to online.
- Employs **exponential backoff** for retries on failed sync attempts (starting at 1s, doubling up to a maximum of 60s) to prevent overloading the server or burning mobile data/battery.

## Sync Guarantees

1. **Idempotency**: Retrying a sync operation multiple times yields the exact same merged state.
2. **Deterministic Merging**: If device A and device B make concurrent modifications offline, merging their states on recovery yields a deterministic result:
- If they modified different fields, the fields are merged.
- If they modified the same field, the latest change (latest timestamp) wins.
- If one deleted the subscription and the other updated it, the deletion wins unless the update timestamp is strictly greater than the deletion tombstone timestamp.
3. **No Message Ordering Reliance**: The state is synchronized as a whole CRDT payload. Packet loss, duplicates, or out-of-order packets do not impact final consistency.
101 changes: 101 additions & 0 deletions src/hooks/__tests__/useOfflineSync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { useOfflineSync } from '../useOfflineSync';
import { networkMonitor } from '../../services/network/networkMonitor';
import { useSubscriptionStore } from '../../store/subscriptionStore';
import { expect, describe, it, beforeEach, afterEach, jest } from '@jest/globals';

jest.mock('../../services/network/networkMonitor', () => {
let isOnlineValue = true;
const listeners = new Set<(connected: boolean) => void>();
return {
networkMonitor: {
isOnline: () => isOnlineValue,
subscribe: (cb: (connected: boolean) => void) => {
listeners.add(cb);
cb(isOnlineValue);
return () => listeners.delete(cb);
},
setOnline: (status: boolean) => {
isOnlineValue = status;
listeners.forEach((cb) => cb(status));
},
},
};
});

describe('useOfflineSync hook', () => {
beforeEach(() => {
jest.useFakeTimers();
useSubscriptionStore.setState({
syncStatus: 'idle',
subscriptions: [],
crdtMetadata: {},
syncWithServer: jest.fn(() => Promise.resolve()),
});
});

afterEach(() => {
jest.useRealTimers();
});

it('initially returns online status and store syncStatus', () => {
networkMonitor.setOnline(true);
const { result } = renderHook(() => useOfflineSync());

expect(result.current.isOnline).toBe(true);
expect(result.current.syncStatus).toBe('idle');
});

it('updates online status when network changes', () => {
const { result } = renderHook(() => useOfflineSync());

act(() => {
networkMonitor.setOnline(false);
});

expect(result.current.isOnline).toBe(false);
});

it('triggers syncWithServer immediately when connection is restored', () => {
const syncSpy = jest.fn(() => Promise.resolve());
useSubscriptionStore.setState({
syncWithServer: syncSpy,
});

networkMonitor.setOnline(false);
renderHook(() => useOfflineSync());

act(() => {
networkMonitor.setOnline(true);
});

expect(syncSpy).toHaveBeenCalledTimes(1);
});

it('retries sync operation with exponential backoff on failure', async () => {
let callCount = 0;
const syncSpy = jest.fn(() => {
callCount++;
return Promise.reject(new Error('Sync failed'));
});

useSubscriptionStore.setState({
syncWithServer: syncSpy,
});

networkMonitor.setOnline(true);
renderHook(() => useOfflineSync());

expect(syncSpy).toHaveBeenCalledTimes(1);

await act(async () => {
jest.advanceTimersByTime(1000);
});
expect(syncSpy).toHaveBeenCalledTimes(2);

await act(async () => {
jest.advanceTimersByTime(2000);
});
expect(syncSpy).toHaveBeenCalledTimes(3);
});
});
64 changes: 64 additions & 0 deletions src/hooks/useOfflineSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { networkMonitor } from '../services/network/networkMonitor';
import { useSubscriptionStore } from '../store/subscriptionStore';

export function useOfflineSync() {
const [isOnline, setIsOnline] = useState(networkMonitor.isOnline());
const syncStatus = useSubscriptionStore((state) => state.syncStatus);
const syncWithServer = useSubscriptionStore((state) => state.syncWithServer);

const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const backoffDelayRef = useRef<number>(1000); // start with 1 second

const triggerSyncWithBackoff = useCallback(async () => {
// Clear any pending retries
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}

if (!networkMonitor.isOnline()) {
return;
}

try {
await syncWithServer();
// On success, reset the backoff delay
backoffDelayRef.current = 1000;
} catch (err) {
// Exponential backoff
const nextDelay = Math.min(backoffDelayRef.current * 2, 60000); // cap at 60s
console.warn(`Sync failed, retrying in ${backoffDelayRef.current}ms`, err);

retryTimeoutRef.current = setTimeout(() => {
triggerSyncWithBackoff();
}, backoffDelayRef.current);

backoffDelayRef.current = nextDelay;
}
}, [syncWithServer]);

useEffect(() => {
const unsubscribe = networkMonitor.subscribe((connected) => {
setIsOnline(connected);
if (connected) {
// Reset backoff delay on new connection recovery
backoffDelayRef.current = 1000;
triggerSyncWithBackoff();
}
});

return () => {
unsubscribe();
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
}
};
}, [triggerSyncWithBackoff]);

return {
isOnline,
syncStatus,
sync: triggerSyncWithBackoff,
};
}
155 changes: 155 additions & 0 deletions src/services/cache/__tests__/crdt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { expect, describe, it } from '@jest/globals';
import { SubscriptionCRDT, CRDTSubscriptionState } from '../crdt';
import { Subscription, SubscriptionCategory, BillingCycle } from '../../../types/subscription';

const mockSubscription = (id: string, name: string, price: number, updatedAt: Date): Subscription => ({
id,
name,
price,
currency: 'USD',
billingCycle: BillingCycle.MONTHLY,
category: SubscriptionCategory.STREAMING,
nextBillingDate: new Date('2026-07-01T00:00:00Z'),
isActive: true,
notificationsEnabled: true,
isCryptoEnabled: false,
createdAt: new Date('2026-06-01T00:00:00Z'),
updatedAt,
});

describe('SubscriptionCRDT', () => {
it('creates metadata with correct timestamps', () => {
const sub = mockSubscription('sub-1', 'Netflix', 15, new Date('2026-06-28T09:00:00Z'));
const meta = SubscriptionCRDT.createMetadata(sub, 1000);

expect(meta.timestamps.name).toBe(1000);
expect(meta.timestamps.price).toBe(1000);
expect(meta.deletedAt).toBeUndefined();
});

it('updates metadata with newer timestamps for updated fields', () => {
const sub = mockSubscription('sub-1', 'Netflix', 15, new Date('2026-06-28T09:00:00Z'));
const meta = SubscriptionCRDT.createMetadata(sub, 1000);

const updatedMeta = SubscriptionCRDT.updateMetadata(meta, { price: 16 }, 2000);
expect(updatedMeta.timestamps.price).toBe(2000);
expect(updatedMeta.timestamps.name).toBe(1000);
});

it('merges two divergent states using field-level LWW', () => {
const subA = mockSubscription('sub-1', 'Netflix A', 15, new Date('2026-06-28T09:00:00Z'));
const metaA = {
timestamps: { name: 1000, price: 1000 },
};

const subB = mockSubscription('sub-1', 'Netflix B', 18, new Date('2026-06-28T09:00:00Z'));
const metaB = {
timestamps: { name: 2000, price: 500 },
};

const stateA: CRDTSubscriptionState = {
subscriptions: { 'sub-1': subA },
metadata: { 'sub-1': metaA },
};

const stateB: CRDTSubscriptionState = {
subscriptions: { 'sub-1': subB },
metadata: { 'sub-1': metaB },
};

const merged = SubscriptionCRDT.merge(stateA, stateB);
const mergedSub = merged.subscriptions['sub-1'];
const mergedMeta = merged.metadata['sub-1'];

expect(mergedSub.name).toBe('Netflix B');
expect(mergedSub.price).toBe(15);
expect(mergedMeta.timestamps.name).toBe(2000);
expect(mergedMeta.timestamps.price).toBe(1000);
});

it('handles tombstones: deletion overrides updates if deletedAt >= max field timestamp', () => {
const sub = mockSubscription('sub-1', 'Netflix', 15, new Date());
const stateA: CRDTSubscriptionState = {
subscriptions: { 'sub-1': sub },
metadata: {
'sub-1': {
timestamps: { name: 1000, price: 1000 },
},
},
};

const stateB: CRDTSubscriptionState = {
subscriptions: {},
metadata: {
'sub-1': {
timestamps: { name: 1000, price: 1000 },
deletedAt: 1500,
},
},
};

const merged = SubscriptionCRDT.merge(stateA, stateB);
expect(merged.subscriptions['sub-1']).toBeUndefined();
expect(merged.metadata['sub-1'].deletedAt).toBe(1500);
});

it('handles tombstones: update overrides deletion if updated field is newer than deletedAt', () => {
const sub = mockSubscription('sub-1', 'Netflix Updated', 20, new Date());
const stateA: CRDTSubscriptionState = {
subscriptions: { 'sub-1': sub },
metadata: {
'sub-1': {
timestamps: { name: 2000, price: 2000 },
},
},
};

const stateB: CRDTSubscriptionState = {
subscriptions: {},
metadata: {
'sub-1': {
timestamps: { name: 1000, price: 1000 },
deletedAt: 1500,
},
},
};

const merged = SubscriptionCRDT.merge(stateA, stateB);
expect(merged.subscriptions['sub-1']).toBeDefined();
expect(merged.subscriptions['sub-1'].name).toBe('Netflix Updated');
expect(merged.metadata['sub-1'].deletedAt).toBe(1500);
});

it('is commutative, associative, and idempotent', () => {
const subA = mockSubscription('sub-1', 'Netflix A', 15, new Date());
const subB = mockSubscription('sub-1', 'Netflix B', 18, new Date());
const subC = mockSubscription('sub-1', 'Netflix C', 20, new Date());

const stateA: CRDTSubscriptionState = {
subscriptions: { 'sub-1': subA },
metadata: { 'sub-1': { timestamps: { name: 1000, price: 1000 } } },
};

const stateB: CRDTSubscriptionState = {
subscriptions: { 'sub-1': subB },
metadata: { 'sub-1': { timestamps: { name: 2000, price: 500 } } },
};

const stateC: CRDTSubscriptionState = {
subscriptions: { 'sub-1': subC },
metadata: { 'sub-1': { timestamps: { name: 1500, price: 1500 } } },
};

const mergeAB = SubscriptionCRDT.merge(stateA, stateB);
const mergeBA = SubscriptionCRDT.merge(stateB, stateA);
expect(mergeAB).toEqual(mergeBA);

const mergeAA = SubscriptionCRDT.merge(stateA, stateA);
expect(mergeAA.subscriptions['sub-1'].name).toBe(stateA.subscriptions['sub-1'].name);

const mergeAB_C = SubscriptionCRDT.merge(mergeAB, stateC);
const mergeBC = SubscriptionCRDT.merge(stateB, stateC);
const mergeA_BC = SubscriptionCRDT.merge(stateA, mergeBC);
expect(mergeAB_C).toEqual(mergeA_BC);
});
});
Loading
Loading