UNPKG

@dataql/react-native

Version:

DataQL React Native SDK with offline-first capabilities and clean API

446 lines (402 loc) 11.4 kB
import { eq, and, desc, asc } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import { DatabaseClient } from "../db/client"; import * as schema from "../db/schema"; import type { OfflineOperation, MutationResult, QueryResult, SyncStatus, } from "../types"; export class OfflineCacheManager { private dbClient: DatabaseClient; constructor(dbClient: DatabaseClient) { this.dbClient = dbClient; } private get db() { return this.dbClient.getDatabase(); } // Create a new record offline async createOffline<T>(tableName: string, data: T): Promise<MutationResult> { try { const offlineId = uuidv4(); const now = new Date(); // Store in cached data await this.db.insert(schema.cachedData).values({ id: uuidv4(), tableName, itemId: offlineId, data: data as any, createdAt: now, updatedAt: now, }); // Queue for sync await this.db.insert(schema.offlineOperations).values({ id: uuidv4(), type: "create", tableName, data: data as any, timestamp: now, status: "pending", retryCount: 0, }); return { success: true, data, offlineId, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } // Update a record offline async updateOffline<T>( tableName: string, itemId: string, data: Partial<T> ): Promise<MutationResult> { try { const now = new Date(); // Update cached data const existingRecord = await this.db .select() .from(schema.cachedData) .where( and( eq(schema.cachedData.tableName, tableName), eq(schema.cachedData.itemId, itemId), eq(schema.cachedData.isDeleted, false) ) ) .limit(1); if (existingRecord.length === 0) { return { success: false, error: "Record not found", }; } const currentData = existingRecord[0].data as any; const updatedData = { ...currentData, ...data }; await this.db .update(schema.cachedData) .set({ data: updatedData, updatedAt: now, }) .where( and( eq(schema.cachedData.tableName, tableName), eq(schema.cachedData.itemId, itemId) ) ); // Queue for sync await this.db.insert(schema.offlineOperations).values({ id: uuidv4(), type: "update", tableName, data: { id: itemId, ...data } as any, timestamp: now, status: "pending", retryCount: 0, }); return { success: true, data: updatedData, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } // Delete a record offline async deleteOffline( tableName: string, itemId: string ): Promise<MutationResult> { try { const now = new Date(); // Mark as deleted in cached data await this.db .update(schema.cachedData) .set({ isDeleted: true, updatedAt: now, }) .where( and( eq(schema.cachedData.tableName, tableName), eq(schema.cachedData.itemId, itemId) ) ); // Queue for sync await this.db.insert(schema.offlineOperations).values({ id: uuidv4(), type: "delete", tableName, data: { id: itemId } as any, timestamp: now, status: "pending", retryCount: 0, }); return { success: true, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } // Query data from local cache async queryOffline<T>( tableName: string, _filter?: any // Prefix with underscore to indicate intentionally unused ): Promise<QueryResult<T>> { try { const query = this.db .select() .from(schema.cachedData) .where( and( eq(schema.cachedData.tableName, tableName), eq(schema.cachedData.isDeleted, false) ) ) .orderBy(desc(schema.cachedData.updatedAt)); const results = await query; const data = results.map((record) => ({ id: record.itemId, ...(record.data as any), _localId: record.id, _createdAt: record.createdAt, _updatedAt: record.updatedAt, })) as T[]; return { data, isFromCache: true, lastUpdated: results[0]?.updatedAt, }; } catch (error) { return { data: [], error: error instanceof Error ? error.message : "Unknown error", isFromCache: true, }; } } // Get pending operations for sync async getPendingOperations(limit = 50): Promise<OfflineOperation[]> { try { const operations = await this.db .select() .from(schema.offlineOperations) .where(eq(schema.offlineOperations.status, "pending")) .orderBy(asc(schema.offlineOperations.timestamp)) .limit(limit); return operations as OfflineOperation[]; } catch (error) { console.error("Error fetching pending operations:", error); return []; } } // Mark operation as synced async markOperationSynced( operationId: string, serverId?: string ): Promise<void> { try { await this.db .update(schema.offlineOperations) .set({ status: "synced", serverId, }) .where(eq(schema.offlineOperations.id, operationId)); } catch (error) { console.error("Error marking operation as synced:", error); } } // Mark operation as failed async markOperationFailed(operationId: string, error: string): Promise<void> { try { const operation = await this.db .select() .from(schema.offlineOperations) .where(eq(schema.offlineOperations.id, operationId)) .limit(1); if (operation.length > 0) { await this.db .update(schema.offlineOperations) .set({ status: "failed", error, retryCount: operation[0].retryCount + 1, }) .where(eq(schema.offlineOperations.id, operationId)); } } catch (err) { console.error("Error marking operation as failed:", err); } } // Get sync status async getSyncStatus(): Promise<SyncStatus> { try { const pendingCount = await this.db .select() .from(schema.offlineOperations) .where(eq(schema.offlineOperations.status, "pending")); const syncingCount = await this.db .select() .from(schema.offlineOperations) .where(eq(schema.offlineOperations.status, "syncing")); const lastSyncTime = await this.getSetting("lastSyncTime"); return { isOnline: navigator.onLine ?? true, lastSyncTime: lastSyncTime ? new Date(lastSyncTime) : null, pendingOperations: pendingCount.length, failedOperations: 0, // TODO: implement failed operations count syncInProgress: syncingCount.length > 0, }; } catch (error) { return { isOnline: false, lastSyncTime: null, pendingOperations: 0, failedOperations: 0, syncInProgress: false, syncError: error instanceof Error ? error.message : "Unknown error", }; } } // Settings helpers private async setSetting(key: string, value: any): Promise<void> { try { await this.db .insert(schema.appSettings) .values({ key, value: typeof value === "string" ? value : JSON.stringify(value), type: typeof value === "string" ? "string" : "json", updatedAt: new Date(), }) .onConflictDoUpdate({ target: schema.appSettings.key, set: { value: typeof value === "string" ? value : JSON.stringify(value), type: typeof value === "string" ? "string" : "json", updatedAt: new Date(), }, }); } catch (error) { console.error("Error setting app setting:", error); } } private async getSetting(key: string): Promise<any> { try { const result = await this.db .select() .from(schema.appSettings) .where(eq(schema.appSettings.key, key)) .limit(1); if (result.length === 0) return null; const setting = result[0]; if (setting.type === "json") { return JSON.parse(setting.value || ""); } return setting.value; } catch (error) { console.error("Error getting app setting:", error); return null; } } // Upsert a record offline (create if doesn't exist, update if it does) async upsertOffline<T>( tableName: string, data: T & { id: string } ): Promise<MutationResult> { try { const now = new Date(); // Check if record exists const existingRecord = await this.db .select() .from(schema.cachedData) .where( and( eq(schema.cachedData.tableName, tableName), eq(schema.cachedData.itemId, data.id), eq(schema.cachedData.isDeleted, false) ) ) .limit(1); if (existingRecord.length === 0) { // Create new record await this.db.insert(schema.cachedData).values({ id: uuidv4(), tableName, itemId: data.id, data: data as any, createdAt: now, updatedAt: now, }); // Queue for sync await this.db.insert(schema.offlineOperations).values({ id: uuidv4(), type: "create", tableName, data: data as any, timestamp: now, status: "pending", retryCount: 0, }); return { success: true, data, offlineId: data.id, isCreate: true, }; } else { // Update existing record const currentData = existingRecord[0].data as any; const updatedData = { ...currentData, ...data }; await this.db .update(schema.cachedData) .set({ data: updatedData, updatedAt: now, }) .where( and( eq(schema.cachedData.tableName, tableName), eq(schema.cachedData.itemId, data.id) ) ); // Queue for sync await this.db.insert(schema.offlineOperations).values({ id: uuidv4(), type: "update", tableName, data: data as any, timestamp: now, status: "pending", retryCount: 0, }); return { success: true, data: updatedData, isCreate: false, }; } } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error", }; } } }