@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
JavaScript
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();