Tests. Autocomplete. Few Fixes. Mocks for Electrum Service. Template-to-Json parser. Fix global paths. Use IO Dependency injection for logging from cli. Additional commands in CLI.
This commit is contained in:
@@ -7,10 +7,10 @@ import {
|
||||
import type { XOInvitation } from "@xo-cash/types";
|
||||
|
||||
import { Invitation } from "./invitation.js";
|
||||
import { Storage } from "./storage.js";
|
||||
import { BaseStorage, Storage } from "./storage.js";
|
||||
import { SyncServer } from "../utils/sync-server.js";
|
||||
import { HistoryService } from "./history.js";
|
||||
import { ElectrumService } from "./electrum.js";
|
||||
import { type BlockchainService, ElectrumService } from "./electrum.js";
|
||||
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
|
||||
@@ -42,10 +42,10 @@ export interface AppConfig {
|
||||
|
||||
export class AppService extends EventEmitter<AppEventMap> {
|
||||
public engine: Engine;
|
||||
public storage: Storage;
|
||||
public storage: BaseStorage;
|
||||
public config: AppConfig;
|
||||
public history: HistoryService;
|
||||
public electrum: ElectrumService;
|
||||
public electrum: BlockchainService;
|
||||
|
||||
public invitations: Invitation[] = [];
|
||||
private invitationEventCleanup = new Map<
|
||||
@@ -101,9 +101,9 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
|
||||
constructor(
|
||||
engine: Engine,
|
||||
storage: Storage,
|
||||
storage: BaseStorage,
|
||||
config: AppConfig,
|
||||
electrum: ElectrumService,
|
||||
electrum: BlockchainService,
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -224,14 +224,14 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
*/
|
||||
async unreserveAllResources(): Promise<number> {
|
||||
const allUnspentOutputs = await this.engine.listUnspentOutputsData();
|
||||
const reserved = allUnspentOutputs.filter((o) => o.reserved);
|
||||
const reserved = allUnspentOutputs.filter((o) => o.reservedBy);
|
||||
|
||||
// Group by invitation identifier so the engine can clear them properly.
|
||||
const byInvitation = new Map<string, typeof reserved>();
|
||||
for (const output of reserved) {
|
||||
const existing = byInvitation.get(output.invitationIdentifier) ?? [];
|
||||
const existing = byInvitation.get(output.reservedBy!) ?? [];
|
||||
existing.push(output);
|
||||
byInvitation.set(output.invitationIdentifier, existing);
|
||||
byInvitation.set(output.reservedBy!, existing);
|
||||
}
|
||||
|
||||
for (const [invitationIdentifier, outputs] of byInvitation) {
|
||||
|
||||
@@ -8,6 +8,10 @@ export interface ElectrumServiceConfig {
|
||||
applicationIdentifier?: string;
|
||||
}
|
||||
|
||||
export abstract class BlockchainService {
|
||||
abstract hasSeenTransaction(transactionHash: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small Electrum adapter used by CLI services.
|
||||
* Keeps connection logic in one place and exposes a tiny API.
|
||||
|
||||
@@ -401,7 +401,7 @@ export class HistoryService {
|
||||
return {
|
||||
kind: "utxo",
|
||||
id: this.getUtxoId(utxo),
|
||||
invitationIdentifier: utxo.invitationIdentifier || undefined,
|
||||
invitationIdentifier: utxo.reservedBy || undefined,
|
||||
templateIdentifier: utxo.templateIdentifier,
|
||||
outputIdentifier: utxo.outputIdentifier,
|
||||
outpoint: {
|
||||
@@ -409,7 +409,7 @@ export class HistoryService {
|
||||
index: utxo.outpointIndex,
|
||||
},
|
||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||
reserved: utxo.reserved,
|
||||
reserved: utxo.reservedBy ? true : false,
|
||||
direction,
|
||||
description,
|
||||
descriptionParts: {
|
||||
@@ -517,7 +517,7 @@ export class HistoryService {
|
||||
utxo: UnspentOutputData,
|
||||
invitationByUtxoOrigin: Map<string, UtxoOriginContext>,
|
||||
): string | undefined {
|
||||
if (utxo.invitationIdentifier) return utxo.invitationIdentifier;
|
||||
if (utxo.reservedBy) return utxo.reservedBy;
|
||||
const originKey = this.getUtxoOriginKey(
|
||||
utxo.templateIdentifier,
|
||||
utxo.outputIdentifier,
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
|
||||
import type { SSEvent } from "../utils/sse-client.js";
|
||||
import type { SyncServer } from "../utils/sync-server.js";
|
||||
import type { Storage } from "./storage.js";
|
||||
import type { ElectrumService } from "./electrum.js";
|
||||
import type { BaseStorage } from "./storage.js";
|
||||
import type { BlockchainService } from "./electrum.js";
|
||||
|
||||
import { EventEmitter } from "../utils/event-emitter.js";
|
||||
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
|
||||
@@ -39,9 +39,9 @@ export type InvitationEventMap = {
|
||||
|
||||
export type InvitationDependencies = {
|
||||
syncServer: SyncServer;
|
||||
storage: Storage;
|
||||
storage: BaseStorage;
|
||||
engine: Engine;
|
||||
electrum: ElectrumService;
|
||||
electrum: BlockchainService;
|
||||
};
|
||||
|
||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
@@ -119,8 +119,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
* The storage instance.
|
||||
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
||||
*/
|
||||
private storage: Storage;
|
||||
private electrum: ElectrumService;
|
||||
private storage: BaseStorage;
|
||||
private electrum: BlockchainService;
|
||||
|
||||
/**
|
||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
||||
|
||||
export class Storage {
|
||||
export abstract class BaseStorage {
|
||||
abstract all(): Promise<{ key: string; value: any }[]>;
|
||||
abstract set(key: string, value: any): Promise<void>;
|
||||
abstract get(key: string): Promise<any>;
|
||||
abstract remove(key: string): Promise<void>;
|
||||
abstract clear(): Promise<void>;
|
||||
abstract child(key: string): BaseStorage;
|
||||
}
|
||||
|
||||
export class Storage extends BaseStorage {
|
||||
static async create(dbPath: string): Promise<Storage> {
|
||||
// Create the database
|
||||
const database = new Database(dbPath);
|
||||
@@ -19,7 +28,9 @@ export class Storage {
|
||||
constructor(
|
||||
private readonly database: Database.Database,
|
||||
private readonly basePath: string,
|
||||
) {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full key with basePath prefix
|
||||
@@ -117,3 +128,104 @@ export class Storage {
|
||||
return new Storage(this.database, this.getFullKey(key));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory storage adapter with the same namespaced API as {@link Storage}.
|
||||
*
|
||||
* This adapter is useful for tests and short-lived sessions where persisted
|
||||
* SQLite state is not needed.
|
||||
*/
|
||||
export class InMemoryStorage extends BaseStorage {
|
||||
static async create(): Promise<InMemoryStorage> {
|
||||
return new InMemoryStorage(new Map<string, string>(), "");
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly store: Map<string, string>,
|
||||
private readonly basePath: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full key with basePath prefix.
|
||||
*/
|
||||
private getFullKey(key: string): string {
|
||||
return this.basePath ? `${this.basePath}.${key}` : key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the basePath prefix from a key.
|
||||
*/
|
||||
private stripBasePath(fullKey: string): string {
|
||||
if (!this.basePath) return fullKey;
|
||||
const prefix = `${this.basePath}.`;
|
||||
return fullKey.startsWith(prefix) ? fullKey.slice(prefix.length) : fullKey;
|
||||
}
|
||||
|
||||
async set(key: string, value: any): Promise<void> {
|
||||
const fullKey = this.getFullKey(key);
|
||||
const encodedValue = encodeExtendedJson(value);
|
||||
this.store.set(fullKey, encodedValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all key-value pairs from this storage namespace (shallow only).
|
||||
*/
|
||||
async all(): Promise<{ key: string; value: any }[]> {
|
||||
const rows: Array<{ key: string; value: string }> = [];
|
||||
const prefix = this.basePath ? `${this.basePath}.` : "";
|
||||
|
||||
for (const [key, value] of this.store.entries()) {
|
||||
if (this.basePath && !key.startsWith(prefix)) continue;
|
||||
rows.push({ key, value });
|
||||
}
|
||||
|
||||
const filteredRows = rows.filter((row) => {
|
||||
const strippedKey = this.stripBasePath(row.key);
|
||||
return !strippedKey.includes(".");
|
||||
});
|
||||
|
||||
return filteredRows.map((row) => ({
|
||||
key: this.stripBasePath(row.key),
|
||||
value: decodeExtendedJson(row.value),
|
||||
}));
|
||||
}
|
||||
|
||||
async get(key: string): Promise<any> {
|
||||
const fullKey = this.getFullKey(key);
|
||||
const encodedValue = this.store.get(fullKey);
|
||||
if (encodedValue === undefined) return null;
|
||||
|
||||
return decodeExtendedJson(encodedValue);
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
const fullKey = this.getFullKey(key);
|
||||
this.store.delete(fullKey);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
if (!this.basePath) {
|
||||
this.store.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const prefix = `${this.basePath}.`;
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
for (const key of this.store.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToDelete) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
child(key: string): InMemoryStorage {
|
||||
return new InMemoryStorage(this.store, this.getFullKey(key));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user