@decent-stuff/dc-client
Version:
High-performance WebAssembly client for browser-based querying of Decent Cloud ledger data
393 lines (349 loc) • 13.6 kB
text/typescript
import Dexie, { Table } 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 {
ledgerBlocks!: Table<LedgerBlock, number>;
ledgerEntries!: Table<LedgerEntry, string>;
// Flag to track if auto-heal was attempted
private autoHealAttempted = false;
// Error field to store database errors
private _error: string | null = null;
constructor() {
console.info(`Initializing ${DB_NAME}...`);
super(DB_NAME);
// 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();
}
/**
* Utility method to handle database operations with consistent error handling
* @param operationName Name of the operation for error messages
* @param operation Function that performs the database operation
* @param defaultValue Optional default value to return in case of error
* @returns Result of the operation or defaultValue in case of error
*/
private async withErrorHandling<T>(
operationName: string,
operation: () => Promise<T>,
defaultValue: T
): Promise<T>;
private async withErrorHandling<T>(
operationName: string,
operation: () => Promise<T>
): Promise<T>;
private async withErrorHandling<T>(
operationName: string,
operation: () => Promise<T>,
defaultValue?: T
): Promise<T> {
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 as T;
}
throw error;
}
}
/**
* Helper method to perform a transaction on both ledger tables
* @param operation Function that performs operations within the transaction
*/
private async withTransaction(operation: () => Promise<void>): Promise<void> {
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
*/
private async initialize(): Promise<void> {
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: unknown) {
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(): Promise<void> {
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(): Promise<LedgerBlock | null> {
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: LedgerBlock[], newEntries: LedgerEntry[]): Promise<void> {
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(): Promise<LedgerEntry[]> {
return this.withErrorHandling(
'get all entries',
async () => await this.ledgerEntries.toArray(),
[]
);
}
/**
* Get all ledger blocks
* @returns All ledger blocks
*/
async getAllBlocks(): Promise<LedgerBlock[]> {
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: number): Promise<LedgerEntry[]> {
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: string, key: string): Promise<LedgerEntry[]> {
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: string): Promise<LedgerEntry[]> {
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: string): Promise<LedgerEntry | undefined> {
return this.withErrorHandling(
'get entry',
async () => await this.ledgerEntries.get(key),
undefined
);
}
/**
* Clear all ledger entries from the database
*/
async clearAllEntries(): Promise<void> {
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(): Promise<void> {
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(): string | null {
return this._error;
}
/**
* Set the database error
* @param error The error message to set
*/
setError(error: string | null): void {
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();
export interface LedgerBlock {
blockVersion: number;
blockSize: number;
parentBlockHash: string;
blockHash: string;
blockOffset: number;
fetchCompareBytes: string;
fetchOffset: number;
timestampNs: number;
}
export interface LedgerEntry {
id?: number; // Auto-incrementing primary key
label: string;
key: string;
value: unknown;
description: string;
blockOffset: number;
}