@dataql/react-native
Version:
DataQL React Native SDK with offline-first capabilities and clean API
446 lines (402 loc) • 11.4 kB
text/typescript
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",
};
}
}
}