UNPKG

@decent-stuff/dc-client

Version:

High-performance WebAssembly client for browser-based querying of Decent Cloud ledger data

271 lines (270 loc) 11.2 kB
import Dexie from 'dexie'; // Constants const DB_NAME = 'DecentCloudLedgerDB'; /** * LedgerDatabase class for managing ledger data in IndexedDB * Uses Dexie as the IndexedDB wrapper */ class LedgerDatabase extends Dexie { constructor() { console.info(`Initializing ${DB_NAME}...`); super(DB_NAME); // Flag to track if auto-heal was attempted this.autoHealAttempted = false; // Error field to store database errors this._error = null; // Define stores in a single version declaration this.version(6).stores({ ledgerBlocks: 'blockOffset, timestampNs', ledgerEntries: '++id, *label, *key, *blockOffset', }); // Initialize the database asynchronously void this.initialize(); } async withErrorHandling(operationName, operation, defaultValue) { try { // Clear any previous error this.setError(null); // Execute the operation return await operation(); } catch (error) { console.error(`Error ${operationName}:`, error); // Set the error message if (error instanceof Error) { this.setError(`Failed to ${operationName}: ${error.message}`); } else { this.setError(`Failed to ${operationName}: ${String(error)}`); } // Check if this is a database schema error that could benefit from auto-healing if (typeof error === 'object' && error !== null && 'name' in error && 'message' in error && typeof error.name === 'string' && typeof error.message === 'string' && ((error.name === "DatabaseClosedError" && error.message.includes('Not yet support for changing primary key')) || (error.name === "VersionError" && error.message.includes('Schema was extended')) || (error.name === "InvalidStateError" && error.message.includes('database schema'))) && !this.autoHealAttempted) { console.warn(`Detected database schema issue during ${operationName}. Attempting auto-heal...`); // Mark that we've attempted auto-heal to prevent infinite loops this.autoHealAttempted = true; // Perform auto-heal await this.performAutoHeal(); console.warn('Auto-heal completed. Retrying operation...'); // Retry the operation after auto-heal try { return await operation(); } catch (retryError) { console.error(`Error retrying ${operationName} after auto-heal:`, retryError); // Fall through to return default or rethrow } } // Return default value or rethrow based on whether defaultValue is provided if (arguments.length >= 3) { return defaultValue; } throw error; } } /** * Helper method to perform a transaction on both ledger tables * @param operation Function that performs operations within the transaction */ async withTransaction(operation) { await this.transaction('rw', [this.ledgerBlocks, this.ledgerEntries], operation); } /** * Initialize the database asynchronously * This method is called from the constructor and handles async operations * that cannot be performed directly in the constructor */ async initialize() { try { // We don't use withErrorHandling here because we need special error handling for auto-heal // Clear any previous error this.setError(null); // Attempt to get the last block to verify database is working await this.getLastBlock(); console.info(`${DB_NAME} initialized successfully.`); } catch (error) { console.error("Error initializing database:", error); // Set the error message if (error instanceof Error) { this.setError(`Database initialization error: ${error.message}`); } else { this.setError(`Database initialization error: ${String(error)}`); } // Auto-heal for primary key change errors // Type guard to check if error is an object with name and message properties if (typeof error === 'object' && error !== null && 'name' in error && 'message' in error && typeof error.name === 'string' && typeof error.message === 'string' && error.name === "DatabaseClosedError" && error.message.includes('Not yet support for changing primary key') && !this.autoHealAttempted) { console.warn('Detected primary key change error. Attempting auto-heal by deleting database...'); // Mark that we've attempted auto-heal to prevent infinite loops this.autoHealAttempted = true; // Perform auto-heal await this.performAutoHeal(); // Log that auto-heal was completed console.warn('Auto-heal completed. Please reload the application.'); } // We don't throw here since this is an async method called from the constructor // The error will be logged, and the application should handle reconnection logic } } /** * Perform the auto-heal process by deleting and recreating the database */ async performAutoHeal() { await this.withErrorHandling('auto-heal database', async () => { // Delete the database await Dexie.delete(DB_NAME); console.log('Database deleted successfully as part of auto-heal process.'); // The database will be recreated on next access }); } /** * Get the last ledger entry (with the highest timestamp) * @returns The last ledger entry or null if no entries exist */ async getLastBlock() { return this.withErrorHandling('get last block', async () => await this.ledgerBlocks.orderBy('timestampNs').last() || null, null); } /** * Add or update multiple ledger entries in a single transaction * Also updates the last entry if any of the new entries has a higher timestamp * @param newBlocks The ledger blocks to add * @param newEntries The ledger entries to add or update */ async bulkAddOrUpdate(newBlocks, newEntries) { if (newEntries.length === 0) return; await this.withErrorHandling('add or update entries', async () => { await this.withTransaction(async () => { // Add or update all blocks await this.ledgerBlocks.bulkPut(newBlocks); // Add or update all entries await this.ledgerEntries.bulkPut(newEntries); }); }); } /** * Get all ledger entries * @returns All ledger entries */ async getAllEntries() { return this.withErrorHandling('get all entries', async () => await this.ledgerEntries.toArray(), []); } /** * Get all ledger blocks * @returns All ledger blocks */ async getAllBlocks() { return this.withErrorHandling('get all blocks', async () => await this.ledgerBlocks.toArray(), []); } /** * Retrieve entries for a specific block. * * @param blockOffset The offset of the block to retrieve entries for. * @returns {Promise<LedgerEntry[]>} An array of ledger entries for the specified block. */ async getBlockEntries(blockOffset) { return this.withErrorHandling('get block entries', async () => { if (typeof blockOffset !== 'number') { const errorMsg = `blockOffset must be a number, got (${typeof blockOffset}) ${blockOffset} instead`; this.setError(errorMsg); throw new Error(errorMsg); } return await this.ledgerEntries.where('blockOffset').equals(blockOffset).toArray(); }, []); } /** * Retrieve entries with a specific label and a substring of the key. */ async getEntriesByLabelAndKey(label, key) { return this.withErrorHandling('get entries by label and key', async () => { const result = await this.ledgerEntries.where('label').equals(label).and((entry) => entry.key.includes(key)).toArray(); console.info(`Found NP Register ${result.length} entries for label ${label} and key ${key}`); return result; }, []); } /** * Retrieve entries with a specific label. * @param label The label of the entries to retrieve * @returns {Promise<LedgerEntry[]>} An array of ledger entries with the specified label */ async getEntriesByLabel(label) { return this.withErrorHandling('get entries by label', async () => await this.ledgerEntries.where('label').equals(label).toArray(), []); } /** * Get a specific ledger entry by key * @param key The key of the entry to get * @returns The ledger entry or undefined if not found */ async getEntry(key) { return this.withErrorHandling('get entry', async () => await this.ledgerEntries.get(key), undefined); } /** * Clear all ledger entries from the database */ async clearAllEntries() { await this.withErrorHandling('clear entries', async () => { await this.withTransaction(async () => { await this.ledgerBlocks.clear(); await this.ledgerEntries.clear(); }); }); } /** * Explicitly delete the database and reset all data * This can be called manually to resolve schema issues or for troubleshooting * @returns Promise that resolves when the database has been deleted */ async resetDatabase() { await this.withErrorHandling('reset database', async () => { // Close the current instance this.close(); // Delete the database await Dexie.delete(DB_NAME); console.log('Database has been completely reset.'); }); } /** * Get the current database error * @returns The current error message or null if no error */ getError() { return this._error; } /** * Set the database error * @param error The error message to set */ setError(error) { if (error !== this._error) { if (error) { console.error('Database error set:', error); } else { console.info('Database error cleared'); } this._error = error; } } } // Create and export a singleton instance of the database export const db = new LedgerDatabase();