From cd547e55ad3fac367a0016d5f1e7d679acb2c0d0 Mon Sep 17 00:00:00 2001 From: rindicomfort Date: Sun, 28 Jun 2026 10:34:20 +0100 Subject: [PATCH] feat: implement offline-first subscription sync layer with CRDT --- docs/offline-architecture.md | 50 +++++ src/hooks/__tests__/useOfflineSync.test.ts | 101 ++++++++++ src/hooks/useOfflineSync.ts | 64 +++++++ src/services/cache/__tests__/crdt.test.ts | 155 +++++++++++++++ src/services/cache/crdt.ts | 163 ++++++++++++++++ src/services/network/networkMonitor.ts | 65 +++++++ src/store/subscriptionStore.ts | 208 ++++++++++++++++++--- 7 files changed, 783 insertions(+), 23 deletions(-) create mode 100644 docs/offline-architecture.md create mode 100644 src/hooks/__tests__/useOfflineSync.test.ts create mode 100644 src/hooks/useOfflineSync.ts create mode 100644 src/services/cache/__tests__/crdt.test.ts create mode 100644 src/services/cache/crdt.ts create mode 100644 src/services/network/networkMonitor.ts diff --git a/docs/offline-architecture.md b/docs/offline-architecture.md new file mode 100644 index 00000000..f8fa41c5 --- /dev/null +++ b/docs/offline-architecture.md @@ -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. diff --git a/src/hooks/__tests__/useOfflineSync.test.ts b/src/hooks/__tests__/useOfflineSync.test.ts new file mode 100644 index 00000000..b0dfed56 --- /dev/null +++ b/src/hooks/__tests__/useOfflineSync.test.ts @@ -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); + }); +}); diff --git a/src/hooks/useOfflineSync.ts b/src/hooks/useOfflineSync.ts new file mode 100644 index 00000000..3ff7fefc --- /dev/null +++ b/src/hooks/useOfflineSync.ts @@ -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(null); + const backoffDelayRef = useRef(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, + }; +} diff --git a/src/services/cache/__tests__/crdt.test.ts b/src/services/cache/__tests__/crdt.test.ts new file mode 100644 index 00000000..7fd69cfe --- /dev/null +++ b/src/services/cache/__tests__/crdt.test.ts @@ -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); + }); +}); diff --git a/src/services/cache/crdt.ts b/src/services/cache/crdt.ts new file mode 100644 index 00000000..523d6d70 --- /dev/null +++ b/src/services/cache/crdt.ts @@ -0,0 +1,163 @@ +import { Subscription } from '../../types/subscription'; + +export interface SubscriptionMetadata { + timestamps: Record; + deletedAt?: number; +} + +export interface CRDTSubscriptionState { + subscriptions: Record; + metadata: Record; +} + +export class SubscriptionCRDT { + /** + * Merges two CRDT subscription states deterministically. + * Returns a new merged state. + */ + static merge(stateA: CRDTSubscriptionState, stateB: CRDTSubscriptionState): CRDTSubscriptionState { + const mergedSubs: Record = {}; + const mergedMeta: Record = {}; + + const allIds = new Set([ + ...Object.keys(stateA.subscriptions || {}), + ...Object.keys(stateB.subscriptions || {}), + ...Object.keys(stateA.metadata || {}), + ...Object.keys(stateB.metadata || {}), + ]); + + for (const id of allIds) { + const metaA = (stateA.metadata && stateA.metadata[id]) || { timestamps: {} }; + const metaB = (stateB.metadata && stateB.metadata[id]) || { timestamps: {} }; + + const deletedAtA = metaA.deletedAt || 0; + const deletedAtB = metaB.deletedAt || 0; + const mergedDeletedAt = Math.max(deletedAtA, deletedAtB); + + // Find the maximum field update timestamp in A and B + const maxUpdateA = Object.values(metaA.timestamps || {}).reduce((max, t) => Math.max(max, t), 0); + const maxUpdateB = Object.values(metaB.timestamps || {}).reduce((max, t) => Math.max(max, t), 0); + const maxUpdate = Math.max(maxUpdateA, maxUpdateB); + + // If deletedAt is newer than any field update, the item is deleted + if (mergedDeletedAt > 0 && mergedDeletedAt >= maxUpdate) { + mergedMeta[id] = { + timestamps: this.mergeTimestamps(metaA.timestamps || {}, metaB.timestamps || {}), + deletedAt: mergedDeletedAt, + }; + // Do not add to mergedSubs (it's tombstoned) + continue; + } + + // Otherwise, the item is present. Let's merge the fields. + const subA = stateA.subscriptions && stateA.subscriptions[id]; + const subB = stateB.subscriptions && stateB.subscriptions[id]; + + if (!subA && !subB) { + continue; + } + + const mergedSub = {} as Partial; + const mergedTimestamps: Record = {}; + + const allKeys = new Set([ + ...Object.keys(subA || {}), + ...Object.keys(subB || {}), + ]) as Set; + + for (const key of allKeys) { + const tA = (metaA.timestamps && metaA.timestamps[key as string]) || 0; + const tB = (metaB.timestamps && metaB.timestamps[key as string]) || 0; + + const valA = subA ? subA[key] : undefined; + const valB = subB ? subB[key] : undefined; + + if (tA > tB) { + if (valA !== undefined) { + (mergedSub as any)[key] = valA; + mergedTimestamps[key as string] = tA; + } + } else if (tB > tA) { + if (valB !== undefined) { + (mergedSub as any)[key] = valB; + mergedTimestamps[key as string] = tB; + } + } else { + // Equal timestamps: use deterministic tie breaker or take first + if (valA !== undefined && valB !== undefined) { + // Convert to string safely for comparison + const strA = typeof valA === 'object' ? JSON.stringify(valA) : String(valA); + const strB = typeof valB === 'object' ? JSON.stringify(valB) : String(valB); + if (strA >= strB) { + (mergedSub as any)[key] = valA; + } else { + (mergedSub as any)[key] = valB; + } + } else if (valA !== undefined) { + (mergedSub as any)[key] = valA; + } else if (valB !== undefined) { + (mergedSub as any)[key] = valB; + } + mergedTimestamps[key as string] = Math.max(tA, tB); + } + } + + // Convert date/string fields back to appropriate types if needed + if (mergedSub.createdAt) mergedSub.createdAt = new Date(mergedSub.createdAt); + if (mergedSub.updatedAt) mergedSub.updatedAt = new Date(mergedSub.updatedAt); + if (mergedSub.nextBillingDate) mergedSub.nextBillingDate = new Date(mergedSub.nextBillingDate); + + mergedSubs[id] = mergedSub as Subscription; + mergedMeta[id] = { + timestamps: mergedTimestamps, + deletedAt: mergedDeletedAt > 0 ? mergedDeletedAt : undefined, + }; + } + + return { + subscriptions: mergedSubs, + metadata: mergedMeta, + }; + } + + private static mergeTimestamps( + tsA: Record, + tsB: Record + ): Record { + const merged: Record = { ...tsA }; + for (const [k, v] of Object.entries(tsB)) { + merged[k] = Math.max(merged[k] || 0, v); + } + return merged; + } + + /** + * Helper to create CRDT metadata for a new subscription. + */ + static createMetadata(sub: Subscription, timestamp: number = Date.now()): SubscriptionMetadata { + const timestamps: Record = {}; + for (const key of Object.keys(sub)) { + timestamps[key] = timestamp; + } + return { timestamps }; + } + + /** + * Helper to update CRDT metadata for updated fields. + */ + static updateMetadata( + currentMeta: SubscriptionMetadata, + updates: Partial, + timestamp: number = Date.now() + ): SubscriptionMetadata { + const currentTimestamps = currentMeta ? currentMeta.timestamps : {}; + const timestamps = { ...currentTimestamps }; + for (const key of Object.keys(updates)) { + timestamps[key] = timestamp; + } + return { + ...currentMeta, + timestamps, + }; + } +} diff --git a/src/services/network/networkMonitor.ts b/src/services/network/networkMonitor.ts new file mode 100644 index 00000000..e96d0f5f --- /dev/null +++ b/src/services/network/networkMonitor.ts @@ -0,0 +1,65 @@ +import NetInfo, { NetInfoState } from '@react-native-community/netinfo'; + +export type NetworkStatusCallback = (isConnected: boolean) => void; + +export class NetworkMonitor { + private listeners: Set = new Set(); + private isConnected: boolean = true; + private unsubscribeNetInfo: (() => void) | null = null; + + constructor() { + this.startMonitoring(); + } + + private startMonitoring() { + this.unsubscribeNetInfo = NetInfo.addEventListener((state: NetInfoState) => { + const connected = state.isConnected !== false; + if (this.isConnected !== connected) { + this.isConnected = connected; + this.notifyListeners(connected); + } + }); + + NetInfo.fetch().then((state: NetInfoState) => { + this.isConnected = state.isConnected !== false; + }); + } + + stopMonitoring() { + if (this.unsubscribeNetInfo) { + this.unsubscribeNetInfo(); + this.unsubscribeNetInfo = null; + } + } + + isOnline(): boolean { + return this.isConnected; + } + + setOnline(status: boolean) { + if (this.isConnected !== status) { + this.isConnected = status; + this.notifyListeners(status); + } + } + + subscribe(callback: NetworkStatusCallback): () => void { + this.listeners.add(callback); + callback(this.isConnected); + return () => { + this.listeners.delete(callback); + }; + } + + private notifyListeners(isConnected: boolean) { + this.listeners.forEach((callback) => { + try { + callback(isConnected); + } catch (err) { + console.error('Error notifying network status listener:', err); + } + }); + } +} + +export const networkMonitor = new NetworkMonitor(); diff --git a/src/store/subscriptionStore.ts b/src/store/subscriptionStore.ts index 8676a0ca..1a330795 100644 --- a/src/store/subscriptionStore.ts +++ b/src/store/subscriptionStore.ts @@ -1,6 +1,9 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { debouncedAsyncStorageAdapter } from '../utils/storage'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { SubscriptionMetadata, CRDTSubscriptionState, SubscriptionCRDT } from '../services/cache/crdt'; +import { networkMonitor } from '../services/network/networkMonitor'; import { Subscription, // eslint-disable-line SubscriptionFormData, @@ -149,6 +152,12 @@ interface SubscriptionState { creditMemos: Record; planChanges: SubscriptionChange[]; + // Offline-first & CRDT Sync + syncStatus: 'idle' | 'pending' | 'syncing' | 'conflict' | 'error'; + crdtMetadata: Record; + syncWithServer: () => Promise; + setSyncStatus: (status: 'idle' | 'pending' | 'syncing' | 'conflict' | 'error') => void; + // Actions addSubscription: (data: SubscriptionFormData) => Promise; updateSubscription: (id: string, data: Partial) => Promise; @@ -180,7 +189,7 @@ interface SubscriptionState { getChangeHistory: (subscriptionId: string) => SubscriptionChange[]; } -type PersistedSubscriptionSlice = Pick; +type PersistedSubscriptionSlice = Pick; const serializeForStorage = (state: PersistedSubscriptionSlice): PersistedSubscriptionSlice => ({ subscriptions: (state.subscriptions || []).map((sub) => ({ @@ -193,6 +202,8 @@ const serializeForStorage = (state: PersistedSubscriptionSlice): PersistedSubscr ...change, createdAt: new Date(change.createdAt), })), + crdtMetadata: state.crdtMetadata || {}, + syncStatus: state.syncStatus || 'idle', }); const migratePersistedState = ( @@ -200,7 +211,7 @@ const migratePersistedState = ( _version: number ): PersistedSubscriptionSlice => { if (!persisted || typeof persisted !== 'object') { - return { subscriptions: [], planChanges: [] }; + return { subscriptions: [], planChanges: [], crdtMetadata: {}, syncStatus: 'idle' }; } const maybeState = persisted as Partial; @@ -215,9 +226,27 @@ const migratePersistedState = ( })) : []; - return { subscriptions, planChanges }; + const crdtMetadata = maybeState.crdtMetadata || {}; + const syncStatus = maybeState.syncStatus || 'idle'; + + return { subscriptions, planChanges, crdtMetadata, syncStatus }; }; +// Simulated backend sync endpoint using AsyncStorage +async function mockSyncApiCall(localState: CRDTSubscriptionState): Promise { + await new Promise((resolve) => setTimeout(resolve, 300)); + + let serverStateRaw = await AsyncStorage.getItem('subtrackr-server-db'); + let serverState: CRDTSubscriptionState = serverStateRaw + ? JSON.parse(serverStateRaw) + : { subscriptions: {}, metadata: {} }; + + const mergedServerState = SubscriptionCRDT.merge(serverState, localState); + await AsyncStorage.setItem('subtrackr-server-db', JSON.stringify(mergedServerState)); + + return mergedServerState; +} + export const useSubscriptionStore = create()( persist( (set, get) => ({ @@ -234,6 +263,54 @@ export const useSubscriptionStore = create()( creditMemos: {}, planChanges: [], + // Offline-first & CRDT Sync State + syncStatus: 'idle', + crdtMetadata: {}, + + setSyncStatus: (status) => set({ syncStatus: status }), + + syncWithServer: async () => { + if (!networkMonitor.isOnline()) { + set({ syncStatus: 'pending' }); + return; + } + if (get().syncStatus === 'syncing') return; + + set({ syncStatus: 'syncing', error: null }); + + try { + const localState: CRDTSubscriptionState = { + subscriptions: get().subscriptions.reduce((acc, sub) => { + acc[sub.id] = sub; + return acc; + }, {} as Record), + metadata: get().crdtMetadata || {}, + }; + + const mergedState = await mockSyncApiCall(localState); + const subscriptionsArray = Object.values(mergedState.subscriptions); + + set({ + subscriptions: subscriptionsArray, + crdtMetadata: mergedState.metadata, + syncStatus: 'idle', + isLoading: false, + }); + + get().calculateStats(); + await syncRenewalReminders(get().subscriptions); + await useCalendarStore.getState().syncSubscriptions(get().subscriptions); + } catch (err) { + const appError = errorHandler.handleError(err as Error, { + action: 'syncWithServer', + }); + set({ + syncStatus: 'error', + error: appError, + }); + } + }, + previewPlanChange: ( id: string, newPrice: number, @@ -282,8 +359,17 @@ export const useSubscriptionStore = create()( ); } + const timestamp = Date.now(); + const currentMeta = (get().crdtMetadata || {})[id] || SubscriptionCRDT.createMetadata(sub, timestamp - 1000); + const updatedMetadata = SubscriptionCRDT.updateMetadata(currentMeta, updates, timestamp); + set((state) => ({ subscriptions: state.subscriptions.map((s) => (s.id === id ? { ...s, ...updates } : s)), + crdtMetadata: { + ...(state.crdtMetadata || {}), + [id]: updatedMetadata, + }, + syncStatus: 'pending', creditMemos: updatedCreditMemos, prorationPreview: null, isLoading: false, @@ -291,6 +377,10 @@ export const useSubscriptionStore = create()( get().calculateStats(); await syncRenewalReminders(get().subscriptions); + + if (networkMonitor.isOnline()) { + await get().syncWithServer(); + } } catch (error) { const appError = errorHandler.handleError(error as Error, { action: 'executePlanChange', @@ -388,8 +478,16 @@ export const useSubscriptionStore = create()( updatedAt: new Date(), }; + const timestamp = Date.now(); + const newMetadata = SubscriptionCRDT.createMetadata(newSubscription, timestamp); + set((state) => ({ subscriptions: [...state.subscriptions, newSubscription], + crdtMetadata: { + ...(state.crdtMetadata || {}), + [newSubscription.id]: newMetadata, + }, + syncStatus: 'pending', isLoading: false, })); @@ -405,6 +503,10 @@ export const useSubscriptionStore = create()( price: data.price, category: data.category, }); + + if (networkMonitor.isOnline()) { + await get().syncWithServer(); + } } catch (error) { const appError = errorHandler.handleError(error as Error, { action: 'addSubscription', @@ -421,18 +523,40 @@ export const useSubscriptionStore = create()( updateSubscription: async (id: string, data: Partial) => { set({ isLoading: true, error: null }); try { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub) throw new Error('Subscription not found'); + + const updatedSubscription = { + ...sub, + ...data, + updatedAt: new Date(), + }; + + const timestamp = Date.now(); + const currentMeta = (get().crdtMetadata || {})[id] || SubscriptionCRDT.createMetadata(sub, timestamp - 1000); + const updatedMetadata = SubscriptionCRDT.updateMetadata(currentMeta, data, timestamp); + set((state) => ({ - subscriptions: state.subscriptions.map((sub) => - sub.id === id ? { ...sub, ...data, updatedAt: new Date() } : sub + subscriptions: state.subscriptions.map((s) => + s.id === id ? updatedSubscription : s ), + crdtMetadata: { + ...(state.crdtMetadata || {}), + [id]: updatedMetadata, + }, + syncStatus: 'pending', isLoading: false, })); get().calculateStats(); await syncRenewalReminders(get().subscriptions); - const updatedSubscription = get().subscriptions.find((sub) => sub.id === id); - if (updatedSubscription) { - await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); + const updated = get().subscriptions.find((s) => s.id === id); + if (updated) { + await useCalendarStore.getState().syncSubscriptionToCalendars(updated); + } + + if (networkMonitor.isOnline()) { + await get().syncWithServer(); } } catch (error) { const appError = errorHandler.handleError(error as Error, { @@ -451,25 +575,41 @@ export const useSubscriptionStore = create()( set({ isLoading: true, error: null }); try { const current = get().subscriptions.find((sub) => sub.id === id); - if (current) { - useSupportStore - .getState() - .createTicket( - createSupportEvent(current, 'cancellation', [ - 'Cancellation requested from subscription management', - 'Subscription marked for removal', - ]) - ); - } + if (!current) throw new Error('Subscription not found'); + + const timestamp = Date.now(); + const currentMeta = (get().crdtMetadata || {})[id] || SubscriptionCRDT.createMetadata(current, timestamp - 1000); + const updatedMetadata = { + ...currentMeta, + deletedAt: timestamp, + }; + + useSupportStore + .getState() + .createTicket( + createSupportEvent(current, 'cancellation', [ + 'Cancellation requested from subscription management', + 'Subscription marked for removal', + ]) + ); set((state) => ({ subscriptions: state.subscriptions.filter((sub) => sub.id !== id), + crdtMetadata: { + ...(state.crdtMetadata || {}), + [id]: updatedMetadata, + }, + syncStatus: 'pending', isLoading: false, })); get().calculateStats(); await syncRenewalReminders(get().subscriptions); await useCalendarStore.getState().removeSubscriptionFromCalendars(id); + + if (networkMonitor.isOnline()) { + await get().syncWithServer(); + } } catch (error) { const appError = errorHandler.handleError(error as Error, { action: 'deleteSubscription', @@ -485,18 +625,40 @@ export const useSubscriptionStore = create()( toggleSubscriptionStatus: async (id: string) => { set({ isLoading: true, error: null }); try { + const sub = get().subscriptions.find((s) => s.id === id); + if (!sub) throw new Error('Subscription not found'); + + const updatedSubscription = { + ...sub, + isActive: !sub.isActive, + updatedAt: new Date(), + }; + + const timestamp = Date.now(); + const currentMeta = (get().crdtMetadata || {})[id] || SubscriptionCRDT.createMetadata(sub, timestamp - 1000); + const updatedMetadata = SubscriptionCRDT.updateMetadata(currentMeta, { isActive: !sub.isActive }, timestamp); + set((state) => ({ - subscriptions: state.subscriptions.map((sub) => - sub.id === id ? { ...sub, isActive: !sub.isActive, updatedAt: new Date() } : sub + subscriptions: state.subscriptions.map((s) => + s.id === id ? updatedSubscription : s ), + crdtMetadata: { + ...(state.crdtMetadata || {}), + [id]: updatedMetadata, + }, + syncStatus: 'pending', isLoading: false, })); get().calculateStats(); await syncRenewalReminders(get().subscriptions); - const updatedSubscription = get().subscriptions.find((sub) => sub.id === id); - if (updatedSubscription) { - await useCalendarStore.getState().syncSubscriptionToCalendars(updatedSubscription); + const updated = get().subscriptions.find((s) => s.id === id); + if (updated) { + await useCalendarStore.getState().syncSubscriptionToCalendars(updated); + } + + if (networkMonitor.isOnline()) { + await get().syncWithServer(); } } catch (error) { const appError = errorHandler.handleError(error as Error, {