Simplify install by baking in memory storage and p2pkh template
This commit is contained in:
536
tests/cli/mocks/xo-storage-in-memory.ts
Normal file
536
tests/cli/mocks/xo-storage-in-memory.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import type { XOInvitation, XOTemplate } from '@xo-cash/types';
|
||||
|
||||
import {
|
||||
DatabaseClosedError,
|
||||
DatabaseNotInitializedError,
|
||||
QueryFieldsLimitExceededError,
|
||||
QueryInvalidError,
|
||||
UnspentOutputNotFoundInStoreError,
|
||||
UnspentOutputReservationConflictError,
|
||||
DatabaseState,
|
||||
EventData,
|
||||
EventDataQuery,
|
||||
ScriptHashData,
|
||||
ScriptHashDataQuery,
|
||||
UnspentOutputData,
|
||||
UnspentOutputDataFilter,
|
||||
UnspentOutputDataQuery,
|
||||
UnspentOutputStatus,
|
||||
XOStorage,
|
||||
} from '@xo-cash/state';
|
||||
|
||||
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||
|
||||
/**
|
||||
* Returns the names of all fields in a query object that have a defined value.
|
||||
*
|
||||
* @param query - The query object to inspect
|
||||
* @returns Array of field names whose values are not undefined
|
||||
*/
|
||||
export const getDefinedQueryFields = (query: Record<string, unknown>): string[] => {
|
||||
const definedFields: string[] = [];
|
||||
|
||||
for (const [ field, value ] of Object.entries(query)) {
|
||||
if (value !== undefined) {
|
||||
definedFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
return definedFields;
|
||||
};
|
||||
|
||||
/**
|
||||
* In-memory storage adapter implementation.
|
||||
*
|
||||
* This adapter mirrors the behavior of the IndexedDB implementation while keeping
|
||||
* all data in process memory. It is useful for tests and ephemeral sessions where
|
||||
* persistence across process restarts is not required.
|
||||
*/
|
||||
export class InMemoryStorage implements XOStorage {
|
||||
/**
|
||||
* Lifecycle state for this in-memory adapter instance.
|
||||
*/
|
||||
private databaseState: DatabaseState = DatabaseState.Uninitialized;
|
||||
|
||||
/**
|
||||
* Pending initialization promise used to deduplicate concurrent initialize calls.
|
||||
*/
|
||||
private databaseInitializationPromise: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Template records keyed by template identifier.
|
||||
*/
|
||||
private templates = new Map<string, XOTemplate>();
|
||||
|
||||
/**
|
||||
* Invitation records keyed by invitation identifier.
|
||||
*/
|
||||
private invitations = new Map<string, XOInvitation>();
|
||||
|
||||
/**
|
||||
* Script hash records keyed by script hash.
|
||||
*/
|
||||
private scriptHashes = new Map<string, ScriptHashData>();
|
||||
|
||||
/**
|
||||
* Unspent output records keyed by outpoint hash:index.
|
||||
*/
|
||||
private unspentOutputs = new Map<string, UnspentOutputData>();
|
||||
|
||||
/**
|
||||
* Event records keyed by event identifier.
|
||||
*/
|
||||
private events = new Map<string, EventData>();
|
||||
|
||||
/**
|
||||
* Initializes the storage adapter.
|
||||
*
|
||||
* @returns Promise that resolves when initialization is complete
|
||||
* @throws {DatabaseClosedError} if the adapter has already been closed
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this.databaseState === DatabaseState.Ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.databaseState === DatabaseState.Closed) {
|
||||
throw new DatabaseClosedError();
|
||||
}
|
||||
|
||||
if (this.databaseInitializationPromise !== null) {
|
||||
return this.databaseInitializationPromise;
|
||||
}
|
||||
|
||||
this.databaseInitializationPromise = Promise.resolve()
|
||||
.then(() => {
|
||||
this.databaseState = DatabaseState.Ready;
|
||||
})
|
||||
.finally(() => {
|
||||
this.databaseInitializationPromise = null;
|
||||
});
|
||||
|
||||
return this.databaseInitializationPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a template from storage by its identifier.
|
||||
*
|
||||
* @param templateIdentifier - Identifier for the template
|
||||
* @returns Promise resolving to the template data, or undefined if not found
|
||||
*/
|
||||
async getTemplate(templateIdentifier: string): Promise<XOTemplate | undefined> {
|
||||
this.ensureReady();
|
||||
const template = this.templates.get(templateIdentifier);
|
||||
|
||||
return template === undefined ? undefined : this.clone(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a template in storage.
|
||||
*
|
||||
* @param template - The template data to store
|
||||
* @returns Promise that resolves when the template is stored
|
||||
*/
|
||||
async storeTemplate(template: XOTemplate): Promise<void> {
|
||||
this.ensureReady();
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
|
||||
this.templates.set(templateIdentifier, this.clone(template));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all templates stored in storage.
|
||||
*
|
||||
* @returns Promise resolving to all stored templates
|
||||
*/
|
||||
async listTemplates(): Promise<XOTemplate[]> {
|
||||
this.ensureReady();
|
||||
|
||||
return Array.from(this.templates.values(), (template) => this.clone(template));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an invitation from storage by its identifier.
|
||||
*
|
||||
* @param invitationIdentifier - Identifier for the invitation
|
||||
* @returns Promise resolving to the invitation data, or undefined if not found
|
||||
*/
|
||||
async getInvitation(invitationIdentifier: string): Promise<XOInvitation | undefined> {
|
||||
this.ensureReady();
|
||||
const invitation = this.invitations.get(invitationIdentifier);
|
||||
|
||||
return invitation === undefined ? undefined : this.clone(invitation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an invitation in storage.
|
||||
*
|
||||
* @param invitationIdentifier - Identifier for the invitation
|
||||
* @param invitation - The invitation data to store
|
||||
* @returns Promise that resolves when the invitation is stored
|
||||
*/
|
||||
async storeInvitation(invitationIdentifier: string, invitation: XOInvitation): Promise<void> {
|
||||
this.ensureReady();
|
||||
this.invitations.set(invitationIdentifier, this.clone(invitation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves script hash data from storage by script hash.
|
||||
*
|
||||
* @param scriptHash - Script hash to retrieve
|
||||
* @returns Promise resolving to script hash data, or undefined if not found
|
||||
*/
|
||||
async getScriptHashData(scriptHash: string): Promise<ScriptHashData | undefined> {
|
||||
this.ensureReady();
|
||||
const scriptHashData = this.scriptHashes.get(scriptHash);
|
||||
|
||||
return scriptHashData === undefined ? undefined : this.clone(scriptHashData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores script hash data in storage.
|
||||
*
|
||||
* @param data - Script hash data to store
|
||||
* @returns Promise that resolves when data is stored
|
||||
*/
|
||||
async storeScriptHashData(data: ScriptHashData): Promise<void> {
|
||||
this.ensureReady();
|
||||
this.scriptHashes.set(data.scriptHash, this.clone(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the last derivative index used for standard locking bytecodes.
|
||||
* Filters by templateIdentifier and outputIdentifier, and optionally by invitationIdentifier.
|
||||
*
|
||||
* @param templateIdentifier - The template identifier to filter by
|
||||
* @param outputIdentifier - The output identifier to filter by
|
||||
* @param invitationIdentifier - Optional invitation identifier to filter by
|
||||
* @returns Promise resolving to the last derivative index as a string, or null if none found
|
||||
*/
|
||||
async getLastDerivativeIndexForP2PKH(
|
||||
templateIdentifier: string,
|
||||
outputIdentifier: string,
|
||||
invitationIdentifier?: string,
|
||||
): Promise<string | null> {
|
||||
this.ensureReady();
|
||||
const allLockingBytecodes = await this.listScriptHashData({ templateIdentifier });
|
||||
|
||||
const filtered = allLockingBytecodes.filter((lockingBytecodeData) =>
|
||||
lockingBytecodeData.derivationSource?.lockingType === 'standard'
|
||||
&& lockingBytecodeData.outputIdentifier === outputIdentifier
|
||||
&& lockingBytecodeData.derivationSource?.derivativeIndex !== undefined
|
||||
&& (invitationIdentifier === undefined || lockingBytecodeData.invitationIdentifier === invitationIdentifier));
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return filtered.reduce((maximumDerivativeIndex, lockingBytecodeData) => {
|
||||
const currentDerivativeIndex = lockingBytecodeData.derivationSource?.derivativeIndex ?? '0';
|
||||
const currentNum = parseInt(currentDerivativeIndex, 10);
|
||||
const maxNum = parseInt(maximumDerivativeIndex, 10);
|
||||
|
||||
return currentNum > maxNum ? currentDerivativeIndex : maximumDerivativeIndex;
|
||||
}, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves unspent output data from storage.
|
||||
*
|
||||
* @param outpointTransactionHash - Outpoint transaction hash
|
||||
* @param outpointIndex - Outpoint index
|
||||
* @returns Promise resolving to unspent output data, or undefined if not found
|
||||
*/
|
||||
async getUnspentOutputData(outpointTransactionHash: string, outpointIndex: number): Promise<UnspentOutputData | undefined> {
|
||||
this.ensureReady();
|
||||
const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||
const output = this.unspentOutputs.get(key);
|
||||
|
||||
return output === undefined ? undefined : this.clone(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores unspent output data in storage.
|
||||
*
|
||||
* @param data - Unspent output data to store
|
||||
* @returns Promise that resolves when data is stored
|
||||
*/
|
||||
async storeUnspentOutputData(data: UnspentOutputData): Promise<void> {
|
||||
this.ensureReady();
|
||||
const key = this.getUnspentOutputKey(data.outpointTransactionHash, data.outpointIndex);
|
||||
|
||||
this.unspentOutputs.set(key, this.clone(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the status of an existing unspent output.
|
||||
*
|
||||
* @param outpointTransactionHash - Outpoint transaction hash
|
||||
* @param outpointIndex - Outpoint index
|
||||
* @param status - New status to set
|
||||
* @returns Promise that resolves when the update is complete
|
||||
* @throws {UnspentOutputNotFoundInStoreError} if the output doesn't exist
|
||||
*/
|
||||
async updateUnspentOutputDataStatus(outpointTransactionHash: string, outpointIndex: number, status: UnspentOutputStatus): Promise<void> {
|
||||
this.ensureReady();
|
||||
const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||
const existing = this.unspentOutputs.get(key);
|
||||
|
||||
if (existing === undefined) {
|
||||
throw new UnspentOutputNotFoundInStoreError(key);
|
||||
}
|
||||
|
||||
this.unspentOutputs.set(key, {
|
||||
...existing,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets reservation status of unspent outputs in bulk.
|
||||
*
|
||||
* @param outpoints - The outpoints to reserve or unreserve
|
||||
* @param shouldBeReserved - Whether outputs should be reserved
|
||||
* @param invitationIdentifier - Invitation identifier owning the reservation
|
||||
* @returns Promise that resolves when reservation updates complete
|
||||
* @throws {UnspentOutputNotFoundInStoreError} if any output doesn't exist
|
||||
* @throws {UnspentOutputReservationConflictError} if any output is held by another invitation
|
||||
*/
|
||||
async executeBulkUnspentOutputReservation(
|
||||
outpoints: Array<{ outpointTransactionHash: string; outpointIndex: number }>,
|
||||
shouldBeReserved: boolean,
|
||||
invitationIdentifier: string,
|
||||
): Promise<void> {
|
||||
this.ensureReady();
|
||||
|
||||
const outputsToUpdate: Array<{ storageKey: string; updatedOutput: UnspentOutputData }> = [];
|
||||
|
||||
for (const { outpointTransactionHash, outpointIndex } of outpoints) {
|
||||
const storageKey = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||
const existingOutput = this.unspentOutputs.get(storageKey);
|
||||
|
||||
if (existingOutput === undefined) {
|
||||
throw new UnspentOutputNotFoundInStoreError(storageKey);
|
||||
}
|
||||
|
||||
if (shouldBeReserved) {
|
||||
if (existingOutput.reservedBy === invitationIdentifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingOutput.reservedBy !== undefined) {
|
||||
throw new UnspentOutputReservationConflictError(storageKey, existingOutput.reservedBy);
|
||||
}
|
||||
} else {
|
||||
if (existingOutput.reservedBy === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingOutput.reservedBy !== invitationIdentifier) {
|
||||
throw new UnspentOutputReservationConflictError(storageKey, existingOutput.reservedBy);
|
||||
}
|
||||
}
|
||||
|
||||
outputsToUpdate.push({
|
||||
storageKey,
|
||||
updatedOutput: {
|
||||
...existingOutput,
|
||||
reservedBy: shouldBeReserved ? invitationIdentifier : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const { storageKey, updatedOutput } of outputsToUpdate) {
|
||||
this.unspentOutputs.set(storageKey, updatedOutput);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes unspent output data from storage.
|
||||
*
|
||||
* @param outpointTransactionHash - Outpoint transaction hash
|
||||
* @param outpointIndex - Outpoint index
|
||||
* @returns Promise that resolves when deletion is complete
|
||||
*/
|
||||
async deleteUnspentOutputData(outpointTransactionHash: string, outpointIndex: number): Promise<void> {
|
||||
this.ensureReady();
|
||||
const key = this.getUnspentOutputKey(outpointTransactionHash, outpointIndex);
|
||||
|
||||
this.unspentOutputs.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists script hash data matching the provided query.
|
||||
*
|
||||
* @param query - Query to apply
|
||||
* @returns Promise resolving to matching script hash records
|
||||
* @throws {QueryFieldsLimitExceededError} if more than one query field is provided
|
||||
* @throws {QueryInvalidError} if the query contains no recognized field
|
||||
*/
|
||||
async listScriptHashData(query: ScriptHashDataQuery): Promise<ScriptHashData[]> {
|
||||
this.ensureReady();
|
||||
const definedFields = getDefinedQueryFields(query);
|
||||
|
||||
if (definedFields.length > 1) {
|
||||
throw new QueryFieldsLimitExceededError('scriptHashes', definedFields);
|
||||
}
|
||||
|
||||
if (query.templateIdentifier !== undefined) {
|
||||
return this.filterScriptHashes((item) => item.templateIdentifier === query.templateIdentifier);
|
||||
}
|
||||
|
||||
if (query.invitationIdentifier !== undefined) {
|
||||
return this.filterScriptHashes((item) => item.invitationIdentifier === query.invitationIdentifier);
|
||||
}
|
||||
|
||||
if (query.scriptHash !== undefined) {
|
||||
return this.filterScriptHashes((item) => item.scriptHash === query.scriptHash);
|
||||
}
|
||||
|
||||
throw new QueryInvalidError('scriptHashes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists unspent output data matching query and optional in-memory filter.
|
||||
*
|
||||
* @param query - Query for selecting unspent outputs
|
||||
* @param filter - Optional filter for reserved status
|
||||
* @returns Promise resolving to matching unspent outputs
|
||||
* @throws {QueryFieldsLimitExceededError} if more than one query field is provided
|
||||
*/
|
||||
async listUnspentOutputs(query: UnspentOutputDataQuery = {}, filter?: UnspentOutputDataFilter): Promise<UnspentOutputData[]> {
|
||||
this.ensureReady();
|
||||
const definedFields = getDefinedQueryFields(query);
|
||||
|
||||
if (definedFields.length > 1) {
|
||||
throw new QueryFieldsLimitExceededError('unspentOutputs', definedFields);
|
||||
}
|
||||
|
||||
let unspentOutputs = Array.from(this.unspentOutputs.values(), (output) => this.clone(output));
|
||||
|
||||
if (query.templateIdentifier !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((output) => output.templateIdentifier === query.templateIdentifier);
|
||||
} else if (query.reservedBy !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((output) => output.reservedBy === query.reservedBy);
|
||||
} else if (query.scriptHash !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((output) => output.scriptHash === query.scriptHash);
|
||||
} else if (query.status !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((output) => output.status === query.status);
|
||||
}
|
||||
|
||||
if (filter?.reserved !== undefined) {
|
||||
unspentOutputs = unspentOutputs.filter((unspentOutput) => (unspentOutput.reservedBy !== undefined) === filter.reserved);
|
||||
}
|
||||
|
||||
return unspentOutputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an event in storage.
|
||||
*
|
||||
* @param eventId - Event identifier
|
||||
* @param eventType - Event type
|
||||
* @param data - Event payload
|
||||
* @param timestamp - Optional timestamp, defaults to Date.now()
|
||||
* @returns Promise that resolves when event is stored
|
||||
*/
|
||||
async storeEvent(eventId: string, eventType: string, data: unknown, timestamp?: number): Promise<void> {
|
||||
this.ensureReady();
|
||||
this.events.set(eventId, {
|
||||
eventId,
|
||||
timestamp: timestamp ?? Date.now(),
|
||||
eventType,
|
||||
data: this.clone(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists event data matching the provided query.
|
||||
*
|
||||
* @param query - Query to apply
|
||||
* @returns Promise resolving to matching events
|
||||
* @throws {QueryFieldsLimitExceededError} if more than one query field is provided
|
||||
* @throws {QueryInvalidError} if the query contains no recognized field
|
||||
*/
|
||||
async listEvents(query: EventDataQuery): Promise<EventData[]> {
|
||||
this.ensureReady();
|
||||
const definedFields = getDefinedQueryFields(query);
|
||||
|
||||
if (definedFields.length > 1) {
|
||||
throw new QueryFieldsLimitExceededError('events', definedFields);
|
||||
}
|
||||
|
||||
if (query.eventType !== undefined) {
|
||||
return Array.from(this.events.values())
|
||||
.filter((event) => event.eventType === query.eventType)
|
||||
.map((event) => this.clone(event));
|
||||
}
|
||||
|
||||
throw new QueryInvalidError('events');
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the storage adapter and releases in-memory resources.
|
||||
*
|
||||
* @returns Promise that resolves when close operation finishes
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
this.templates.clear();
|
||||
this.invitations.clear();
|
||||
this.scriptHashes.clear();
|
||||
this.unspentOutputs.clear();
|
||||
this.events.clear();
|
||||
|
||||
this.databaseState = DatabaseState.Closed;
|
||||
this.databaseInitializationPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the adapter is in a ready state before handling any operation.
|
||||
* Mirrors the behavior of the IndexedDB implementation where calls after its been closed throw an error.
|
||||
*
|
||||
* @throws {DatabaseClosedError} if the adapter has already been closed
|
||||
* @throws {DatabaseNotInitializedError} if initialize has not been called
|
||||
*/
|
||||
private ensureReady(): void {
|
||||
if (this.databaseState === DatabaseState.Closed) {
|
||||
throw new DatabaseClosedError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the canonical storage key for unspent outputs.
|
||||
*
|
||||
* @param outpointTransactionHash - Outpoint transaction hash
|
||||
* @param outpointIndex - Outpoint index
|
||||
* @returns Canonical key in hash:index format
|
||||
*/
|
||||
private getUnspentOutputKey(outpointTransactionHash: string, outpointIndex: number): string {
|
||||
return `${outpointTransactionHash}:${outpointIndex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns script hash records matching the given predicate.
|
||||
*
|
||||
* @param predicate - Predicate used to filter script hash records
|
||||
* @returns Matching script hash records
|
||||
*/
|
||||
private filterScriptHashes(predicate: (item: ScriptHashData) => boolean): ScriptHashData[] {
|
||||
return Array.from(this.scriptHashes.values())
|
||||
.filter(predicate)
|
||||
.map((item) => this.clone(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a detached clone to preserve storage value semantics.
|
||||
*
|
||||
* IndexedDB deserializes values when reading, so callers cannot mutate data that is
|
||||
* still stored in the database. We clone in-memory values to keep that same behavior.
|
||||
*
|
||||
* @param value - Value to clone
|
||||
* @returns Detached clone of the provided value
|
||||
*/
|
||||
private clone<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user