UNPKG

@dataql/react-native

Version:

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

390 lines (389 loc) 15.2 kB
import { BaseWAL } from "../../../core/src/lib/wal/BaseWAL"; import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; import { eq, and, gte, lt, sql } from "drizzle-orm"; // WAL schema for SQLite export const walEntries = sqliteTable("wal_entries", { id: text("id").primaryKey(), walId: text("wal_id").notNull().unique(), sessionId: text("session_id").notNull(), collection: text("collection").notNull(), operation: text("operation").notNull(), data: text("data").notNull(), // JSON string previousData: text("previous_data"), // JSON string transactionId: text("transaction_id"), status: text("status").notNull(), checksum: text("checksum"), metadata: text("metadata"), // JSON string timestamp: integer("timestamp").notNull(), createdAt: integer("created_at").notNull(), updatedAt: integer("updated_at"), }); export const walTransactions = sqliteTable("wal_transactions", { id: text("id").primaryKey(), walId: text("wal_id").notNull().unique(), sessionId: text("session_id").notNull(), status: text("status").notNull(), operationCount: integer("operation_count").notNull(), timestamp: integer("timestamp").notNull(), createdAt: integer("created_at").notNull(), updatedAt: integer("updated_at"), }); export const walRecoveryPoints = sqliteTable("wal_recovery_points", { id: text("id").primaryKey(), walId: text("wal_id").notNull().unique(), sessionId: text("session_id").notNull(), walEntryId: text("wal_entry_id").notNull(), timestamp: integer("timestamp").notNull(), createdAt: integer("created_at").notNull(), }); export class SQLiteWAL extends BaseWAL { constructor(db, config = {}) { super(config); this.db = db; this.initializeSchema(); } async initializeSchema() { try { // Create indexes for better performance await this.db.run(` CREATE INDEX IF NOT EXISTS idx_wal_entries_session_timestamp ON wal_entries(session_id, timestamp) `); await this.db.run(` CREATE INDEX IF NOT EXISTS idx_wal_entries_status_timestamp ON wal_entries(status, timestamp) `); await this.db.run(` CREATE INDEX IF NOT EXISTS idx_wal_entries_transaction_id ON wal_entries(transaction_id) WHERE transaction_id IS NOT NULL `); await this.db.run(` CREATE INDEX IF NOT EXISTS idx_wal_transactions_session_timestamp ON wal_transactions(session_id, timestamp) `); await this.db.run(` CREATE INDEX IF NOT EXISTS idx_wal_recovery_session_timestamp ON wal_recovery_points(session_id, timestamp) `); } catch (error) { console.warn("Failed to create WAL indexes:", error); } } async persistEntry(entry) { try { const now = Date.now(); await this.db.insert(walEntries).values({ id: `sqlite_${Date.now()}_${Math.random().toString(36).substring(2)}`, walId: entry.id, sessionId: entry.sessionId, collection: entry.collection, operation: entry.operation, data: JSON.stringify(entry.data), previousData: entry.previousData ? JSON.stringify(entry.previousData) : null, transactionId: entry.transactionId || null, status: entry.status, checksum: entry.checksum || null, metadata: entry.metadata ? JSON.stringify(entry.metadata) : null, timestamp: entry.timestamp.getTime(), createdAt: now, updatedAt: null, }); } catch (error) { throw new Error(`Failed to persist WAL entry: ${error instanceof Error ? error.message : "Unknown error"}`); } } async persistTransaction(transaction) { try { const now = Date.now(); // Begin transaction in SQLite await this.db.run("BEGIN TRANSACTION"); try { // Insert transaction record await this.db.insert(walTransactions).values({ id: `sqlite_txn_${Date.now()}_${Math.random().toString(36).substring(2)}`, walId: transaction.id, sessionId: transaction.sessionId, status: transaction.status, operationCount: transaction.operations.length, timestamp: transaction.timestamp.getTime(), createdAt: now, updatedAt: null, }); // Insert all operations for (const operation of transaction.operations) { await this.persistEntry(operation); } await this.db.run("COMMIT"); } catch (error) { await this.db.run("ROLLBACK"); throw error; } } catch (error) { throw new Error(`Failed to persist WAL transaction: ${error instanceof Error ? error.message : "Unknown error"}`); } } async updateEntryStatus(entryId, status) { try { await this.db .update(walEntries) .set({ status, updatedAt: Date.now(), }) .where(eq(walEntries.walId, entryId)); // Check if any rows were affected (SQLite doesn't return rowCount directly) const verification = await this.db .select({ walId: walEntries.walId }) .from(walEntries) .where(eq(walEntries.walId, entryId)) .limit(1); if (verification.length === 0) { throw new Error(`WAL entry ${entryId} not found`); } } catch (error) { throw new Error(`Failed to update WAL entry status: ${error instanceof Error ? error.message : "Unknown error"}`); } } async updateTransactionStatus(transactionId, status) { try { await this.db.run("BEGIN TRANSACTION"); try { // Update transaction status await this.db .update(walTransactions) .set({ status, updatedAt: Date.now(), }) .where(eq(walTransactions.walId, transactionId)); // Update all related operation statuses await this.db .update(walEntries) .set({ status, updatedAt: Date.now(), }) .where(eq(walEntries.transactionId, transactionId)); await this.db.run("COMMIT"); } catch (error) { await this.db.run("ROLLBACK"); throw error; } } catch (error) { throw new Error(`Failed to update WAL transaction status: ${error instanceof Error ? error.message : "Unknown error"}`); } } async fetchUncommittedEntries(sessionId) { try { const whereConditions = [eq(walEntries.status, "pending")]; if (sessionId) { whereConditions.push(eq(walEntries.sessionId, sessionId)); } const query = this.db .select() .from(walEntries) .where(and(...whereConditions)) .orderBy(walEntries.timestamp) .limit(this.config.batchSize); const entries = await query; return entries.map(this.mapToWALEntry); } catch (error) { throw new Error(`Failed to fetch uncommitted entries: ${error instanceof Error ? error.message : "Unknown error"}`); } } async fetchEntriesSince(timestamp, sessionId) { try { const whereConditions = [gte(walEntries.timestamp, timestamp.getTime())]; if (sessionId) { whereConditions.push(eq(walEntries.sessionId, sessionId)); } const query = this.db .select() .from(walEntries) .where(and(...whereConditions)) .orderBy(walEntries.timestamp) .limit(this.config.batchSize); const entries = await query; return entries.map(this.mapToWALEntry); } catch (error) { throw new Error(`Failed to fetch entries since timestamp: ${error instanceof Error ? error.message : "Unknown error"}`); } } async persistRecoveryPoint(recoveryPoint) { try { await this.db.insert(walRecoveryPoints).values({ id: `sqlite_recovery_${Date.now()}_${Math.random().toString(36).substring(2)}`, walId: recoveryPoint.id, sessionId: recoveryPoint.sessionId, walEntryId: recoveryPoint.walEntryId, timestamp: recoveryPoint.timestamp.getTime(), createdAt: Date.now(), }); } catch (error) { throw new Error(`Failed to persist recovery point: ${error instanceof Error ? error.message : "Unknown error"}`); } } async deleteCommittedEntries(olderThan) { try { await this.db.run("BEGIN TRANSACTION"); try { // First, get the count of entries to be deleted const toDelete = await this.db .select({ walId: walEntries.walId }) .from(walEntries) .where(and(eq(walEntries.status, "committed"), lt(walEntries.timestamp, olderThan.getTime()))); // Delete entries await this.db .delete(walEntries) .where(and(eq(walEntries.status, "committed"), lt(walEntries.timestamp, olderThan.getTime()))); // Delete related transactions await this.db .delete(walTransactions) .where(and(eq(walTransactions.status, "committed"), lt(walTransactions.timestamp, olderThan.getTime()))); await this.db.run("COMMIT"); return toDelete.length; } catch (error) { await this.db.run("ROLLBACK"); throw error; } } catch (error) { throw new Error(`Failed to delete committed entries: ${error instanceof Error ? error.message : "Unknown error"}`); } } async deleteSessionEntries(sessionId) { try { await this.db.run("BEGIN TRANSACTION"); try { await Promise.all([ this.db.delete(walEntries).where(eq(walEntries.sessionId, sessionId)), this.db .delete(walTransactions) .where(eq(walTransactions.sessionId, sessionId)), this.db .delete(walRecoveryPoints) .where(eq(walRecoveryPoints.sessionId, sessionId)), ]); await this.db.run("COMMIT"); } catch (error) { await this.db.run("ROLLBACK"); throw error; } } catch (error) { throw new Error(`Failed to delete session entries: ${error instanceof Error ? error.message : "Unknown error"}`); } } mapToWALEntry(entry) { return { id: entry.walId, timestamp: new Date(entry.timestamp), sessionId: entry.sessionId, collection: entry.collection, operation: entry.operation, data: JSON.parse(entry.data), previousData: entry.previousData ? JSON.parse(entry.previousData) : undefined, transactionId: entry.transactionId || undefined, status: entry.status, checksum: entry.checksum || undefined, metadata: entry.metadata ? JSON.parse(entry.metadata) : undefined, }; } /** * Get WAL statistics for monitoring */ async getStatistics() { try { const [entryStats, transactionCount] = await Promise.all([ this.db .select({ status: walEntries.status, count: sql `COUNT(*)`, }) .from(walEntries) .groupBy(walEntries.status), this.db.select({ count: sql `COUNT(*)` }).from(walTransactions), ]); const stats = { totalEntries: 0, pendingEntries: 0, committedEntries: 0, rolledBackEntries: 0, totalTransactions: Number(transactionCount[0]?.count) || 0, }; for (const stat of entryStats) { const count = stat.count; stats.totalEntries += count; switch (stat.status) { case "pending": stats.pendingEntries = count; break; case "committed": stats.committedEntries = count; break; case "rollback": stats.rolledBackEntries = count; break; } } return stats; } catch (error) { throw new Error(`Failed to get WAL statistics: ${error instanceof Error ? error.message : "Unknown error"}`); } } /** * Perform recovery operations by replaying uncommitted entries */ async performRecovery(sessionId, applyMutation) { const uncommittedEntries = await this.getUncommittedEntries(sessionId); let appliedCount = 0; await this.db.run("BEGIN TRANSACTION"); try { for (const entry of uncommittedEntries) { try { await applyMutation(entry); await this.commitEntry(entry.id); appliedCount++; } catch (error) { console.error(`Failed to recover WAL entry ${entry.id}:`, error); await this.rollbackEntry(entry.id); } } await this.db.run("COMMIT"); } catch (error) { await this.db.run("ROLLBACK"); throw error; } return appliedCount; } /** * Vacuum WAL tables to reclaim space */ async vacuum() { try { await this.db.run("VACUUM"); } catch (error) { throw new Error(`Failed to vacuum WAL database: ${error instanceof Error ? error.message : "Unknown error"}`); } } }