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:
2026-04-20 10:30:38 +00:00
parent df4f438f6d
commit ff2fe126c6
44 changed files with 8220 additions and 1503 deletions

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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,

View File

@@ -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).

View File

@@ -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));
}
}