Resolve "WIP: UTXO Selection and Storage"
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user