Resolve "WIP: UTXO Selection and Storage"

This commit is contained in:
Jim Hamill
2025-01-11 08:39:24 +00:00
parent f934216d68
commit 33fd9bac4d
156 changed files with 13304 additions and 4595 deletions
+271
View File
@@ -0,0 +1,271 @@
import { Encryption, EventEmitter } from '../utils';
export type StorageOpts = {
syncInMemory?: boolean;
encryption?: Encryption;
};
export type StoreEvents = {
set: { key: string; value: any };
delete: { key: string };
clear: undefined;
};
export abstract class BaseStorage {
abstract listStores(): Promise<Array<string>>;
abstract createStore(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore>;
abstract getStore(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore | null>;
abstract createOrGetStore(
storeName: string,
opts: StorageOpts,
): Promise<BaseStore>;
abstract deleteStore(storeName: string): Promise<void>;
abstract deleteDatabase(): Promise<void>;
}
/**
* Represents a wrapper object for accessing and manipulating a stored value.
*/
export type StoreValueProxy<T> = {
/**
* Gets the current value from the store
*/
get(): Promise<T | undefined>;
/**
* Gets the current value from the store or fails if undefined.
*/
getOrFail(): Promise<T>;
/**
* Sets a new value in the store
* @param newValue The value to store
*/
set(newValue: T): Promise<void>;
};
/**
* Abstract base class for implementing key-value storage with event emission capabilities.
* Extends EventEmitter to provide event handling for store operations.
*
* @example
* ```typescript
* class MyStore extends BaseStore {
* async keys(): Promise<Array<string>> {
* // Implementation
* }
*
* async get<T>(key: string): Promise<T | undefined> {
* // Implementation
* }
*
* async set<T>(key: string, value: T): Promise<void> {
* // Implementation
* }
*
* async delete(key: string): Promise<void> {
* // Implementation
* }
* }
* ```
*/
export abstract class BaseStore extends EventEmitter<StoreEvents> {
/**
* Returns an array of all keys in the store.
*/
abstract keys(): Promise<Array<string>>;
/**
* Retrieves a value from the store by its key.
*
* @template T The type of the value to retrieve
* @param key The key to lookup
*/
abstract get<T>(key: string): Promise<T | undefined>;
/**
* Stores a value in the store with the specified key.
*
* @template T The type of the value to store
* @param key The key under which to store the value
* @param value The value to store
*/
abstract set<T>(key: string, value: T): Promise<void>;
/**
* Removes a value from the store by its key.
*
* @param key The key to remove
*/
abstract delete(key: string): Promise<void>;
/**
* Retrieves all key-value pairs from the store.
*
* @template T The type of the values in the store
*/
async all<T>(): Promise<{ [key: string]: T }> {
const keys = await this.keys();
const values = await Promise.all(keys.map((key) => this.get<T>(key)));
return keys.reduce(
(acc, key, index) => {
const value = values[index];
if (value !== undefined) {
acc[key] = value;
}
return acc;
},
{} as { [key: string]: T },
);
}
/**
* Removes all key-value pairs from the store.
*/
async clear(): Promise<void> {
const keys = await this.keys();
await Promise.all(keys.map((key) => this.delete(key)));
}
/**
* Checks if a key exists in the store.
*
* @param key The key to check
*/
async has(key: string): Promise<boolean> {
return (await this.get(key)) ? true : false;
}
/**
* Retrieves multiple values from the store by their keys.
* Filters out undefined values from the results.
*
* @template T The type of the values to retrieve
* @param keys Array of keys to retrieve
*/
async batchGet<T>(keys: Array<string>): Promise<Map<string, T>> {
const results = await Promise.all(
keys.map(async (key) => {
const value = await this.get(key);
return [key, value] as const;
}),
);
return new Map(
results.filter(([_, value]) => value !== undefined) as [string, T][],
);
}
/**
* Stores multiple key-value pairs in the store.
* Emits 'set' events for each key-value pair.
*
* @template T The type of the values to store
* @param items Map of key-value pairs to store
*/
async batchSet<T>(items: Map<string, T>): Promise<void> {
await Promise.all(
Array.from(items).map(([key, value]) => {
return this.set(key, value);
}),
);
items.forEach((value, key) => {
this.emit('set', { key, value });
});
}
/**
* Closes the store connection. Override this method in implementing classes if needed.
*/
async close(): Promise<void> {}
/**
* Retrieves a value from the store by its key, throwing an error if the value doesn't exist.
*
* @template T The type of the value to retrieve
* @param key The key to lookup
*/
async getOrFail<T>(key: string): Promise<T> {
// Attempt to get the value from the store.
const value = await this.get<T>(key);
// If the value does not exist, throw an error.
if (typeof value === 'undefined') {
throw new Error(`Failed to get key "${key}"`);
}
// Return the value.
return value;
}
/**
* Retrieves a value from the store by its key, or sets it using the provided setter function if it doesn't exist.
*
* @template T The type of the value to retrieve or set
* @param key The key to lookup
* @param setter Function to generate the value if it doesn't exist
*/
async getOrSet<T>(key: string, setter: () => T | Promise<T>): Promise<T> {
// Attempt to get the value from the store.
let value = await this.get<T>(key);
// If it is undefined, use the setter to set it.
if (typeof value === 'undefined') {
value = await setter();
await this.set(key, value);
}
// Return the value.
return value;
}
/**
* Creates a wrapper object that provides convenient access to a stored value.
* The wrapper allows getting and setting the value using async property access.
*
* @template T The type of the value to wrap
* @param key The key under which the value is stored
* @returns An object with a getter and setter for the stored value
*
* @example
* ```typescript
* const numValue = store.proxy<number>('myNumber');
* const value = await numValue.get(); // gets the stored value
* await numValue.set(42); // sets the stored value
* ```
*/
proxy<T>(key: string): StoreValueProxy<T> {
const self = this;
return {
/**
* Gets the current value from the store
*/
get(): Promise<T | undefined> {
return self.get<T>(key);
},
/**
* Gets the current value from the store or fails if undefined
*/
getOrFail(): Promise<T> {
return self.getOrFail<T>(key);
},
/**
* Sets a new value in the store
*/
async set(newValue: T): Promise<void> {
await self.set(key, newValue);
},
};
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './base-store.js';
export * from './store-inmemory.js';
export * from './store-inmemory-synced.js';
@@ -0,0 +1,255 @@
import { BaseStorage, BaseStore, StorageOpts } from './base-store.js';
import { StoreInMemorySynced } from './store-inmemory-synced.js';
import {
Encryption,
} from '../utils';
import {
encodeExtendedJsonObject,
decodeExtendedJsonObject,
} from '@xocash/primitives';
import { openDB, deleteDB, IDBPDatabase } from 'idb';
export class StorageIndexedDB extends BaseStorage {
private constructor(
private db: IDBPDatabase,
private dbName: string,
) {
super();
}
public static async createOrOpen(dbName: string) {
try {
// Check if database exists by attempting to open it at current version
const existingDb = await openDB(dbName, undefined);
return new StorageIndexedDB(existingDb, dbName);
} catch (error) {
// If database doesn't exist, create it at version 1
const newDb = await openDB(dbName, 1, {
upgrade(_db) {
// Initial database creation - no stores needed by default
},
});
return new StorageIndexedDB(newDb, dbName);
}
}
async listStores() {
return Array.from(this.db.objectStoreNames);
}
async createStore(storeName: string, opts: StorageOpts = {}) {
if (!this.db.objectStoreNames.contains(storeName)) {
const currentVersion = this.db.version;
this.db.close();
const db = await openDB(this.dbName, currentVersion + 1, {
upgrade(db) {
db.createObjectStore(storeName);
},
});
this.db = db;
}
const store = await StoreIndexedDB.create(
this.db,
storeName,
opts.encryption,
);
return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store;
}
async getStore(storeName: string, opts: StorageOpts = {}) {
try {
if (!this.db.objectStoreNames.contains(storeName)) {
return null;
}
const store = await StoreIndexedDB.create(
this.db,
storeName,
opts.encryption,
);
return opts.syncInMemory ? StoreInMemorySynced.create(store) : store;
} catch (error) {
return null;
}
}
async createOrGetStore(storeName: string, opts: StorageOpts = {}) {
const existing = await this.getStore(storeName, opts);
if (existing) {
return existing;
}
return this.createStore(storeName, opts);
}
async deleteStore(storeName: string) {
if (this.db.objectStoreNames.contains(storeName)) {
const currentVersion = this.db.version;
this.db.close();
const db = await openDB(this.dbName, currentVersion + 1, {
upgrade(db) {
db.deleteObjectStore(storeName);
},
});
this.db = db;
}
}
async deleteDatabase() {
this.db.close();
await deleteDB(this.dbName);
}
}
/**
* Implementation of BaseStore using IndexedDB as the storage backend.
*/
export class StoreIndexedDB extends BaseStore {
constructor(
public db: IDBPDatabase,
public storeName: string,
public encryption?: Encryption,
) {
super();
/*
this.broadcastChannel = new BroadcastChannel(
`StoreIndexedDB:${db.name}.${this.storeName}`,
);
this.broadcastChannel.onmessage = (message) => {
this.emit('set', message);
};
*/
}
/**
* Factory method to create a new instance of StoreIndexedDB.
* Provides a more convenient way to instantiate the store when working with promises.
*
* @param db - An instance of an upgraded and opened IndexedDB database
* @param storeName - The name of the object store within the database to use
*
* @example
* ```typescript
* const store = await StoreIndexedDB.create(db, 'myStore');
* ```
*/
static async create(
db: IDBPDatabase,
storeName: string,
encryption?: Encryption,
) {
return new this(db, storeName, encryption);
}
async all<T>() {
const tx = this.db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const keys = await store.getAllKeys();
const values = await store.getAll();
return keys.reduce(
(acc, key, index) => {
acc[key.toString()] = decodeExtendedJsonObject(values[index]);
return acc;
},
{} as { [key: string]: T },
);
}
async keys() {
const tx = this.db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const keys = await store.getAllKeys();
return keys.map((key) => key.toString());
}
async get<T>(key: string) {
const encodedValue = await this.db.get(this.storeName, key);
const decryptedValue = this.encryption
? this.encryption.decrypt(encodedValue)
: encodedValue;
return decodeExtendedJsonObject(decryptedValue) as T;
}
async set<T>(key: string, value: T) {
const encodedValue = encodeExtendedJsonObject(value);
const encryptedValue = this.encryption
? this.encryption.encrypt(encodedValue)
: encodedValue;
await this.db.put(this.storeName, encryptedValue, key);
this.emit('set', { key, value });
}
async delete(key: string) {
await this.db.delete(this.storeName, key);
this.emit('delete', { key });
}
async batchGet<T>(keys: string[]): Promise<Map<string, T>> {
const tx = this.db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const results = await Promise.all(
keys.map(async (key) => {
const value = await store.get(key);
return [
key,
value ? decodeExtendedJsonObject(value) : undefined,
] as const;
}),
);
await tx.done;
return new Map(
results.filter(([_, value]) => value !== undefined) as [string, T][],
);
}
async batchSet<T>(items: Map<string, T>) {
const tx = this.db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
await Promise.all(
Array.from(items).map(([key, value]) => {
const encodedValue = encodeExtendedJsonObject(value);
return store.put(encodedValue, key);
}),
);
await tx.done;
items.forEach((value, key) => {
this.emit('set', { key, value });
});
}
async clear() {
await this.db.clear(this.storeName);
this.emit('clear', undefined);
}
async has(key: string) {
const tx = this.db.transaction(this.storeName, 'readonly');
const store = tx.objectStore(this.storeName);
const result = await store.getKey(key);
return result !== undefined;
}
async close() {
this.db.close();
}
}
@@ -0,0 +1,82 @@
import { BaseStore } from './base-store.js';
import { StoreInMemory } from './store-inmemory.js';
/**
* Storage Adapter that takes another Storage Adapter (e.g. IndexedDB) and "syncs" its contents to system memory for faster read access.
*
* All read operations will use system memory - all write operations will use the provided adapter.
*/
export class StoreInMemorySynced extends BaseStore {
constructor(
private inMemoryCache: StoreInMemory,
private store: BaseStore,
) {
super();
// Hook into all write operations so that we can sync the In-Memory cache.
this.store.on('set', async (payload) => {
await this.inMemoryCache.set(payload.key, payload.value);
this.emit('set', { key: payload.key, value: payload.value });
});
this.store.on('delete', async (payload) => {
await this.inMemoryCache.delete(payload.key);
this.emit('delete', { key: payload.key });
});
this.store.on('clear', async () => {
await this.inMemoryCache.clear();
this.emit('clear', undefined);
});
}
static async create(store: BaseStore) {
// Instantiate in-memory cache and IndexedDB.
const inMemoryCache = new StoreInMemory();
// Create instance of this store.
const memorySyncedStore = new this(inMemoryCache, store);
// Sync the data from IndexedDB into the In-Memory cache.
const indexedDBEntries = await store.all();
for (let [key, value] of Object.entries(indexedDBEntries)) {
inMemoryCache.set(key, value);
}
// Return our instance of this store.
return memorySyncedStore;
}
async all<T>() {
return await this.inMemoryCache.all<T>();
}
async keys() {
return await this.inMemoryCache.keys();
}
async get<T>(key: string) {
// TODO: Consider whether you want to structuredClone this to prevent mutation.
return await this.inMemoryCache.get<T>(key);
}
async set<T>(key: string, value: T) {
await this.store.set(key, value);
}
async delete(key: string) {
await this.store.delete(key);
}
async clear() {
await this.store.clear();
}
async has(key: string) {
return await this.inMemoryCache.has(key);
}
async close() {
await this.store.close();
}
}
@@ -0,0 +1,52 @@
import { BaseStore } from './base-store.js';
/**
* Implementation of BaseStore using Memory as the storage backend.
*
* @remarks
* This implementation can be used testing and caching of expensive operations.
*/
export class StoreInMemory extends BaseStore {
private store: Map<string, any>;
constructor() {
super();
this.store = new Map();
}
async all<T>() {
const result: { [key: string]: T } = {};
for (const [key, value] of this.store.entries()) {
result[key] = value as T;
}
return result;
}
async keys() {
return Array.from(this.store.keys());
}
async get<T>(key: string) {
return this.store.get(key) as T;
}
async set<T>(key: string, value: T) {
this.store.set(key, value);
this.emit('set', { key, value });
}
async delete(key: string) {
this.store.delete(key);
this.emit('delete', { key });
}
async clear() {
this.store.clear();
this.emit('clear', undefined);
}
async has(key: string) {
return this.store.has(key);
}
}