diff --git a/.changeset/project-history-from-operations.md b/.changeset/project-history-from-operations.md new file mode 100644 index 00000000..904f1fcb --- /dev/null +++ b/.changeset/project-history-from-operations.md @@ -0,0 +1,19 @@ +--- +'@cashu/coco-core': major +'@cashu/coco-react': major +'@cashu/coco-indexeddb': major +'@cashu/coco-expo-sqlite': major +'@cashu/coco-sqlite': major +'@cashu/coco-sqlite-bun': major +'@cashu/coco-adapter-tests': major +--- + +Project history entries from operation repositories instead of maintaining a +mutable history table. + +History entries now use deterministic `type:operationId` ids for operation +rows, expose `source`, `updatedAt`, and `operationId` on operation-backed +entries, and retain legacy table rows behind `legacy:*` ids for migration +compatibility. The old history repository mutation contract has been removed; +persistent adapters now read history by merging operation rows with legacy rows +and de-duplicating legacy records that map to an operation. diff --git a/bun.lock b/bun.lock index 110dbd5d..f89122a7 100644 --- a/bun.lock +++ b/bun.lock @@ -17,22 +17,22 @@ }, "packages/adapter-tests": { "name": "@cashu/coco-adapter-tests", - "version": "1.0.0-rc.5", + "version": "1.0.0", "dependencies": { "fake-bolt11": "^0.1.0", }, "devDependencies": { - "@cashu/coco-core": "1.0.0-rc.5", + "@cashu/coco-core": "1.0.0", }, "peerDependencies": { "@cashu/cashu-ts": "^4.1.0", - "@cashu/coco-core": "^1.0.0-rc.5", + "@cashu/coco-core": "^1.0.0", "typescript": "^5", }, }, "packages/core": { "name": "@cashu/coco-core", - "version": "1.0.0-rc.5", + "version": "1.0.0", "dependencies": { "@cashu/cashu-ts": "^4.1.0", "@noble/curves": "^2.0.1", @@ -40,7 +40,7 @@ "@scure/bip32": "^2.0.1", }, "devDependencies": { - "@cashu/coco-adapter-tests": "1.0.0-rc.5", + "@cashu/coco-adapter-tests": "1.0.0", "typescript-eslint": "^8.40.0", }, "peerDependencies": { @@ -55,7 +55,7 @@ }, "packages/expo-sqlite": { "name": "@cashu/coco-expo-sqlite", - "version": "1.0.0-rc.5", + "version": "1.0.0", "devDependencies": { "@cashu/coco-adapter-tests": "1.0.0-rc.2", }, @@ -67,7 +67,7 @@ }, "packages/indexeddb": { "name": "@cashu/coco-indexeddb", - "version": "1.0.0-rc.5", + "version": "1.0.0", "dependencies": { "dexie": "^4.0.8", }, @@ -84,9 +84,9 @@ }, "packages/react": { "name": "@cashu/coco-react", - "version": "1.0.0-rc.5", + "version": "1.0.0", "devDependencies": { - "@cashu/coco-core": "1.0.0-rc.5", + "@cashu/coco-core": "1.0.0", "@eslint/js": "^9.33.0", "@testing-library/react": "^16.3.0", "@types/react": "^19.1.10", @@ -105,13 +105,13 @@ "vitest": "^3.2.4", }, "peerDependencies": { - "@cashu/coco-core": "1.0.0-rc.5", + "@cashu/coco-core": "1.0.0", "react": "^19", }, }, "packages/sqlite-bun": { "name": "@cashu/coco-sqlite-bun", - "version": "1.0.0-rc.5", + "version": "1.0.0", "devDependencies": { "@cashu/coco-adapter-tests": "1.0.0-rc.2", }, @@ -122,7 +122,7 @@ }, "packages/sqlite3": { "name": "@cashu/coco-sqlite", - "version": "1.0.0-rc.5", + "version": "1.0.0", "dependencies": { "better-sqlite3": "^12.6.2", }, diff --git a/packages/adapter-tests/src/integration.ts b/packages/adapter-tests/src/integration.ts index 1a492d5d..9faf8098 100644 --- a/packages/adapter-tests/src/integration.ts +++ b/packages/adapter-tests/src/integration.ts @@ -1132,7 +1132,7 @@ export async function runIntegrationTests { const invoice = createFakeInvoice(15); - const historyPromise = waitForMeltHistoryState(mgr!, 'UNPAID'); + const historyPromise = waitForMeltHistoryState(mgr!, 'prepared'); const prepared = await mgr!.ops.melt.prepare({ mintUrl, method: 'bolt11', @@ -1152,7 +1152,7 @@ export async function runIntegrationTests { + it('should update history state to rolled_back on rollback', async () => { const sendAmount = 35; const pendingPromise = waitForEvent<{ operationId: string }>(mgr!, 'send:pending'); const pendingHistoryPromise = waitForSendHistoryState(mgr!, 'pending', { @@ -1346,7 +1346,7 @@ export async function runIntegrationTests e.type === 'send' && (e as any).operationId === operationId, ); expect(sendEntry).toBeDefined(); - expect((sendEntry as any).state).toBe('rolledBack'); + expect((sendEntry as any).state).toBe('rolled_back'); }); it('should finalize a pending send operation when proofs are spent', async () => { diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index 3bace3f0..1a291764 100644 --- a/packages/core/Manager.ts +++ b/packages/core/Manager.ts @@ -722,12 +722,6 @@ export class Manager { meltQuoteLogger, ); - const historyService = new HistoryService( - repositories.historyRepository, - this.eventBus, - historyLogger, - ); - const mintScopedLock = new MintScopedLock(); const sendOperationLogger = this.getChildLogger('SendOperationService'); @@ -800,6 +794,12 @@ export class Manager { ); const mintOperationRepository = repositories.mintOperationRepository; + const historyService = new HistoryService( + repositories.historyRepository, + this.eventBus, + historyLogger, + ); + const mintQuoteRepository = repositories.mintQuoteRepository; const paymentRequestLogger = this.getChildLogger('PaymentRequestService'); diff --git a/packages/core/models/History.ts b/packages/core/models/History.ts index 78150e9e..992d4552 100644 --- a/packages/core/models/History.ts +++ b/packages/core/models/History.ts @@ -1,56 +1,348 @@ import type { Amount, MeltQuoteState, Token } from '@cashu/cashu-ts'; import type { MintQuoteState } from './MintQuoteState'; +import type { + MeltOperation, + MeltOperationState, + PreparedOrLaterOperation as PreparedOrLaterMeltOperation, +} from '../operations/melt/MeltOperation.ts'; +import type { + MintOperation, + MintOperationState, + PendingOrLaterOperation as PendingOrLaterMintOperation, +} from '../operations/mint/MintOperation.ts'; +import type { + ReceiveOperation, + ReceiveOperationState, +} from '../operations/receive/ReceiveOperation.ts'; +import type { + PreparedOrLaterOperation as PreparedOrLaterSendOperation, + SendOperation, + SendOperationState, +} from '../operations/send/SendOperation.ts'; + +export type HistoryType = 'mint' | 'melt' | 'send' | 'receive'; type BaseHistoryEntry = { id: string; + type: HistoryType; createdAt: number; + updatedAt: number; mintUrl: string; unit: string; metadata?: Record; - operationId?: string; + error?: string; +}; + +type OperationHistoryBase = BaseHistoryEntry & { + source: 'operation'; + operationId: string; }; -export type MintHistoryEntry = BaseHistoryEntry & { +export type MintHistoryState = Exclude; +export type MeltHistoryState = Exclude; +export type SendHistoryState = Exclude; +export type ReceiveHistoryState = Extract; + +export type MintHistoryEntry = OperationHistoryBase & { type: 'mint'; paymentRequest: string; quoteId: string; - state: MintQuoteState; + state: MintHistoryState; amount: Amount; + remoteState?: string; }; -export type MeltHistoryEntry = BaseHistoryEntry & { +export type MeltHistoryEntry = OperationHistoryBase & { type: 'melt'; quoteId: string; - state: MeltQuoteState; + state: MeltHistoryState; amount: Amount; }; -/** - * Simplified state for send history entries. - * Maps from SendOperationState to a user-facing state. - */ -export type SendHistoryState = 'prepared' | 'pending' | 'finalized' | 'rolledBack'; - -export type SendHistoryEntry = BaseHistoryEntry & { +export type SendHistoryEntry = OperationHistoryBase & { type: 'send'; amount: Amount; - operationId: string; state: SendHistoryState; /** Token is only available after execute (state >= pending) */ token?: Token; }; -export type ReceiveHistoryState = 'prepared' | 'finalized' | 'rolledBack'; - -export type ReceiveHistoryEntry = BaseHistoryEntry & { +export type ReceiveHistoryEntry = OperationHistoryBase & { type: 'receive'; amount: Amount; state: ReceiveHistoryState; token?: Token; }; -export type HistoryEntry = +export type OperationHistoryEntry = | MintHistoryEntry | MeltHistoryEntry | SendHistoryEntry | ReceiveHistoryEntry; + +export type LegacyMintHistoryState = MintQuoteState | string; +export type LegacyMeltHistoryState = MeltQuoteState | string; +export type LegacySendHistoryState = 'prepared' | 'pending' | 'finalized' | 'rolledBack' | string; +export type LegacyReceiveHistoryState = 'prepared' | 'finalized' | 'rolledBack' | string; + +type LegacyHistoryBase = BaseHistoryEntry & { + source: 'legacy'; + legacyHistoryId: string; + operationId?: string; +}; + +export type LegacyMintHistoryEntry = LegacyHistoryBase & { + type: 'mint'; + paymentRequest: string; + quoteId: string; + state: LegacyMintHistoryState; + amount: Amount; +}; + +export type LegacyMeltHistoryEntry = LegacyHistoryBase & { + type: 'melt'; + quoteId: string; + state: LegacyMeltHistoryState; + amount: Amount; +}; + +export type LegacySendHistoryEntry = LegacyHistoryBase & { + type: 'send'; + amount: Amount; + state: LegacySendHistoryState; + token?: Token; +}; + +export type LegacyReceiveHistoryEntry = LegacyHistoryBase & { + type: 'receive'; + amount: Amount; + state: LegacyReceiveHistoryState; + token?: Token; +}; + +export type LegacyHistoryEntry = + | LegacyMintHistoryEntry + | LegacyMeltHistoryEntry + | LegacySendHistoryEntry + | LegacyReceiveHistoryEntry; + +export type HistoryEntry = OperationHistoryEntry | LegacyHistoryEntry; + +export type LegacyHistoryRowInput = { + legacyHistoryId: string | number; + type: HistoryType; + createdAt: number; + mintUrl: string; + unit: string; + amount: Amount; + quoteId?: string | null; + state?: string | null; + paymentRequest?: string | null; + token?: Token; + metadata?: Record; + operationId?: string | null; +}; + +export function isOperationHistoryEntry(entry: HistoryEntry): entry is OperationHistoryEntry { + return entry.source === 'operation'; +} + +export function isLegacyHistoryEntry(entry: HistoryEntry): entry is LegacyHistoryEntry { + return entry.source === 'legacy'; +} + +export function operationHistoryId(type: HistoryType, operationId: string): string { + return `${type}:${operationId}`; +} + +export function legacyHistoryId(legacyId: string | number): string { + return `legacy:${legacyId}`; +} + +export function parseHistoryEntryId(id: string): + | { source: 'operation'; type: HistoryType; operationId: string } + | { + source: 'legacy'; + legacyHistoryId: string; + } + | null { + if (id.startsWith('legacy:')) { + const legacyId = id.slice('legacy:'.length); + return legacyId ? { source: 'legacy', legacyHistoryId: legacyId } : null; + } + + const separator = id.indexOf(':'); + if (separator === -1) return null; + const type = id.slice(0, separator) as HistoryType; + const operationId = id.slice(separator + 1); + if (!operationId || !isHistoryType(type)) return null; + return { source: 'operation', type, operationId }; +} + +export function compareHistoryEntries(a: HistoryEntry, b: HistoryEntry): number { + if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt; + return b.id.localeCompare(a.id); +} + +export function projectSendOperation(operation: SendOperation): SendHistoryEntry | null { + if (operation.state === 'init') return null; + + const prepared = operation as PreparedOrLaterSendOperation; + const token = 'token' in prepared ? prepared.token : undefined; + + return { + id: operationHistoryId('send', prepared.id), + source: 'operation', + type: 'send', + createdAt: prepared.createdAt, + updatedAt: prepared.updatedAt, + mintUrl: prepared.mintUrl, + // TODO(custom-unit): use operation.unit once send operation persistence includes units. + unit: token?.unit || 'sat', + operationId: prepared.id, + amount: prepared.amount, + state: prepared.state, + ...(prepared.error ? { error: prepared.error } : {}), + ...(token ? { token } : {}), + }; +} + +export function projectMeltOperation(operation: MeltOperation): MeltHistoryEntry | null { + if (operation.state === 'init' || operation.state === 'failed') return null; + + const prepared = operation as PreparedOrLaterMeltOperation; + return { + id: operationHistoryId('melt', prepared.id), + source: 'operation', + type: 'melt', + createdAt: prepared.createdAt, + updatedAt: prepared.updatedAt, + mintUrl: prepared.mintUrl, + unit: prepared.unit || 'sat', + operationId: prepared.id, + quoteId: prepared.quoteId, + amount: prepared.amount, + state: prepared.state as MeltHistoryState, + ...(prepared.error ? { error: prepared.error } : {}), + }; +} + +export function projectMintOperation(operation: MintOperation): MintHistoryEntry | null { + if (operation.state === 'init') return null; + + const pending = operation as PendingOrLaterMintOperation; + return { + id: operationHistoryId('mint', pending.id), + source: 'operation', + type: 'mint', + createdAt: pending.createdAt, + updatedAt: pending.updatedAt, + mintUrl: pending.mintUrl, + unit: pending.unit, + operationId: pending.id, + quoteId: pending.quoteId, + paymentRequest: pending.request, + amount: pending.amount, + state: pending.state, + ...(pending.lastObservedRemoteState + ? { remoteState: String(pending.lastObservedRemoteState) } + : {}), + ...(pending.error ? { error: pending.error } : {}), + }; +} + +export function projectReceiveOperation(operation: ReceiveOperation): ReceiveHistoryEntry | null { + if (operation.state !== 'finalized' && operation.state !== 'rolled_back') return null; + + const token = + operation.state === 'finalized' + ? { + mint: operation.mintUrl, + proofs: operation.inputProofs, + unit: operation.unit || 'sat', + } + : undefined; + + return { + id: operationHistoryId('receive', operation.id), + source: 'operation', + type: 'receive', + createdAt: operation.createdAt, + updatedAt: operation.updatedAt, + mintUrl: operation.mintUrl, + unit: operation.unit || 'sat', + operationId: operation.id, + amount: operation.amount, + state: operation.state, + ...(operation.error ? { error: operation.error } : {}), + ...(token ? { token } : {}), + }; +} + +export function projectOperationToHistoryEntry( + type: HistoryType, + operation: SendOperation | MeltOperation | MintOperation | ReceiveOperation, +): OperationHistoryEntry | null { + switch (type) { + case 'send': + return projectSendOperation(operation as SendOperation); + case 'melt': + return projectMeltOperation(operation as MeltOperation); + case 'mint': + return projectMintOperation(operation as MintOperation); + case 'receive': + return projectReceiveOperation(operation as ReceiveOperation); + } +} + +export function projectLegacyHistoryRow(row: LegacyHistoryRowInput): LegacyHistoryEntry { + const base = { + id: legacyHistoryId(row.legacyHistoryId), + source: 'legacy' as const, + legacyHistoryId: String(row.legacyHistoryId), + type: row.type, + createdAt: row.createdAt, + updatedAt: row.createdAt, + mintUrl: row.mintUrl, + unit: row.unit, + amount: row.amount, + ...(row.metadata ? { metadata: row.metadata } : {}), + ...(row.operationId ? { operationId: row.operationId } : {}), + }; + + switch (row.type) { + case 'mint': + return { + ...base, + type: 'mint', + quoteId: row.quoteId ?? '', + paymentRequest: row.paymentRequest ?? '', + state: row.state ?? 'UNPAID', + }; + case 'melt': + return { + ...base, + type: 'melt', + quoteId: row.quoteId ?? '', + state: row.state ?? 'UNPAID', + }; + case 'send': + return { + ...base, + type: 'send', + state: row.state ?? 'pending', + ...(row.token ? { token: row.token } : {}), + }; + case 'receive': + return { + ...base, + type: 'receive', + state: row.state ?? 'finalized', + ...(row.token ? { token: row.token } : {}), + }; + } +} + +function isHistoryType(value: string): value is HistoryType { + return value === 'mint' || value === 'melt' || value === 'send' || value === 'receive'; +} diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index b3927a16..b3b2b02c 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -1,13 +1,5 @@ import type { AuthSession } from '@core/models/AuthSession'; -import type { - HistoryEntry, - MeltHistoryEntry, - MintHistoryEntry, - ReceiveHistoryEntry, - ReceiveHistoryState, - SendHistoryEntry, - SendHistoryState, -} from '@core/models/History'; +import type { HistoryEntry } from '@core/models/History'; import type { Keypair } from '@core/models/Keypair'; import type { MeltQuote } from '@core/models/MeltQuote'; import type { MintQuote } from '@core/models/MintQuote'; @@ -128,28 +120,13 @@ export interface MeltQuoteRepository { getPendingMeltQuotes(): Promise; } -export interface HistoryRepository { +export interface HistoryProjectionRepository { getPaginatedHistoryEntries(limit: number, offset: number): Promise; getHistoryEntryById(id: string): Promise; - addHistoryEntry(history: Omit): Promise; - getMintHistoryEntry(mintUrl: string, quoteId: string): Promise; - getMeltHistoryEntry(mintUrl: string, quoteId: string): Promise; - getSendHistoryEntry(mintUrl: string, operationId: string): Promise; - getReceiveHistoryEntry(mintUrl: string, operationId: string): Promise; - updateHistoryEntry(history: Omit): Promise; - updateSendHistoryState( - mintUrl: string, - operationId: string, - state: SendHistoryState, - ): Promise; - updateReceiveHistoryState( - mintUrl: string, - operationId: string, - state: ReceiveHistoryState, - ): Promise; - deleteHistoryEntry(mintUrl: string, quoteId: string): Promise; } +export type HistoryRepository = HistoryProjectionRepository; + export interface SendOperationRepository { /** Create a new send operation */ create(operation: SendOperation): Promise; @@ -265,7 +242,7 @@ interface RepositoriesBase { proofRepository: ProofRepository; mintQuoteRepository: MintQuoteRepository; meltQuoteRepository: MeltQuoteRepository; - historyRepository: HistoryRepository; + historyRepository: HistoryProjectionRepository; sendOperationRepository: SendOperationRepository; meltOperationRepository: MeltOperationRepository; authSessionRepository: AuthSessionRepository; diff --git a/packages/core/repositories/memory/MemoryHistoryRepository.ts b/packages/core/repositories/memory/MemoryHistoryRepository.ts index 2fddf187..5102a1ae 100644 --- a/packages/core/repositories/memory/MemoryHistoryRepository.ts +++ b/packages/core/repositories/memory/MemoryHistoryRepository.ts @@ -1,150 +1,183 @@ -import type { HistoryRepository } from '..'; +import type { HistoryProjectionRepository } from '..'; import type { HistoryEntry, - MintHistoryEntry, - MeltHistoryEntry, + HistoryType, + LegacyHistoryEntry, + LegacyHistoryRowInput, ReceiveHistoryEntry, - ReceiveHistoryState, SendHistoryEntry, - SendHistoryState, } from '@core/models/History'; +import { + compareHistoryEntries, + parseHistoryEntryId, + projectLegacyHistoryRow, + projectMeltOperation, + projectMintOperation, + projectReceiveOperation, + projectSendOperation, +} from '@core/models/History'; +import type { MemoryMeltOperationRepository } from './MemoryMeltOperationRepository'; +import type { MemoryMintOperationRepository } from './MemoryMintOperationRepository'; +import type { MemoryReceiveOperationRepository } from './MemoryReceiveOperationRepository'; +import type { MemorySendOperationRepository } from './MemorySendOperationRepository'; + +type OperationRepositories = { + sendOperationRepository?: MemorySendOperationRepository; + meltOperationRepository?: MemoryMeltOperationRepository; + mintOperationRepository?: MemoryMintOperationRepository; + receiveOperationRepository?: MemoryReceiveOperationRepository; +}; -type NewHistoryEntry = - | Omit - | Omit - | Omit - | Omit; +export class MemoryHistoryRepository implements HistoryProjectionRepository { + private readonly legacyEntries: LegacyHistoryEntry[] = []; -export class MemoryHistoryRepository implements HistoryRepository { - private readonly entries: HistoryEntry[] = []; - private nextId = 1; + constructor(private readonly operationRepositories: OperationRepositories = {}) {} async getPaginatedHistoryEntries(limit: number, offset: number): Promise { - const sorted = [...this.entries].sort((a, b) => { - if (a.createdAt !== b.createdAt) return b.createdAt - a.createdAt; - return Number(b.id) - Number(a.id); - }); - return sorted.slice(offset, offset + limit); + const entries = await this.getProjectedEntries(); + return entries.slice(offset, offset + limit); } async getHistoryEntryById(id: string): Promise { - return this.entries.find((e) => e.id === id) ?? null; - } - - async addHistoryEntry(history: NewHistoryEntry): Promise { - const entry: HistoryEntry = { id: String(this.nextId++), ...history } as HistoryEntry; - this.entries.push(entry); - return entry; - } + const parsed = parseHistoryEntryId(id); + if (!parsed) return null; - async getMintHistoryEntry(mintUrl: string, quoteId: string): Promise { - for (let i = this.entries.length - 1; i >= 0; i--) { - const e = this.entries[i]; - if (!e) continue; - if (e.type === 'mint' && e.mintUrl === mintUrl && e.quoteId === quoteId) return e; + if (parsed.source === 'legacy') { + const entries = await this.getProjectedEntries(); + return entries.find((entry) => entry.id === id && entry.source === 'legacy') ?? null; } - return null; + + return this.projectOperationById(parsed.type, parsed.operationId); } - async getMeltHistoryEntry(mintUrl: string, quoteId: string): Promise { - for (let i = this.entries.length - 1; i >= 0; i--) { - const e = this.entries[i]; - if (!e) continue; - if (e.type === 'melt' && e.mintUrl === mintUrl && e.quoteId === quoteId) return e; - } - return null; + async addLegacyHistoryEntry(history: LegacyHistoryRowInput): Promise { + const entry = projectLegacyHistoryRow(history); + this.legacyEntries.push(entry); + return entry; } async getSendHistoryEntry( mintUrl: string, operationId: string, ): Promise { - for (let i = this.entries.length - 1; i >= 0; i--) { - const e = this.entries[i]; - if (!e) continue; - if (e.type === 'send' && e.mintUrl === mintUrl && e.operationId === operationId) return e; - } - return null; + const operation = + await this.operationRepositories.sendOperationRepository?.getById(operationId); + if (!operation || operation.mintUrl !== mintUrl) return null; + return projectSendOperation(operation); } async getReceiveHistoryEntry( mintUrl: string, operationId: string, ): Promise { - for (let i = this.entries.length - 1; i >= 0; i--) { - const e = this.entries[i]; - if (!e) continue; - if (e.type === 'receive' && e.mintUrl === mintUrl && e.operationId === operationId) { - return e; - } + const operation = + await this.operationRepositories.receiveOperationRepository?.getById(operationId); + if (!operation || operation.mintUrl !== mintUrl) return null; + return projectReceiveOperation(operation); + } + + private async getProjectedEntries(): Promise { + const operationEntries = await this.getOperationEntries(); + const dedupedLegacyEntries = this.dedupeLegacyEntries(operationEntries); + + return [...operationEntries, ...dedupedLegacyEntries].sort(compareHistoryEntries); + } + + private async getOperationEntries(): Promise { + const entries: HistoryEntry[] = []; + + const sendOperations = await this.operationRepositories.sendOperationRepository?.getAll(); + for (const operation of sendOperations ?? []) { + const entry = projectSendOperation(operation); + if (entry) entries.push(entry); + } + + const meltOperations = await this.operationRepositories.meltOperationRepository?.getAll(); + for (const operation of meltOperations ?? []) { + const entry = projectMeltOperation(operation); + if (entry) entries.push(entry); + } + + const mintOperations = await this.operationRepositories.mintOperationRepository?.getAll(); + for (const operation of mintOperations ?? []) { + const entry = projectMintOperation(operation); + if (entry) entries.push(entry); + } + + const receiveOperations = await this.operationRepositories.receiveOperationRepository?.getAll(); + for (const operation of receiveOperations ?? []) { + const entry = projectReceiveOperation(operation); + if (entry) entries.push(entry); } - return null; + + return entries; } - async updateHistoryEntry( - history: - | Omit - | Omit - | Omit - | Omit, - ): Promise { - const idx = this.entries.findIndex((e) => { - if (e.type === 'mint' && history.type === 'mint') { - return e.mintUrl === history.mintUrl && e.quoteId === history.quoteId; + private async projectOperationById( + type: HistoryType, + operationId: string, + ): Promise { + switch (type) { + case 'send': { + const operation = + await this.operationRepositories.sendOperationRepository?.getById(operationId); + return operation ? projectSendOperation(operation) : null; } - if (e.type === 'melt' && history.type === 'melt') { - return e.mintUrl === history.mintUrl && e.quoteId === history.quoteId; + case 'melt': { + const operation = + await this.operationRepositories.meltOperationRepository?.getById(operationId); + return operation ? projectMeltOperation(operation) : null; } - if (e.type === 'send' && history.type === 'send') { - return e.mintUrl === history.mintUrl && e.operationId === history.operationId; + case 'mint': { + const operation = + await this.operationRepositories.mintOperationRepository?.getById(operationId); + return operation ? projectMintOperation(operation) : null; } - if (e.type === 'receive' && history.type === 'receive') { - return e.mintUrl === history.mintUrl && e.operationId === history.operationId; + case 'receive': { + const operation = + await this.operationRepositories.receiveOperationRepository?.getById(operationId); + return operation ? projectReceiveOperation(operation) : null; } - return false; - }); - if (idx === -1) throw new Error('History entry not found'); - const existing = this.entries[idx]; - const updated: HistoryEntry = { ...existing, ...history } as HistoryEntry; - this.entries[idx] = updated; - return updated; - } - - async updateSendHistoryState( - mintUrl: string, - operationId: string, - state: SendHistoryState, - ): Promise { - const entry = await this.getSendHistoryEntry(mintUrl, operationId); - if (!entry) { - throw new Error(`Send history entry not found for operationId: ${operationId}`); } - entry.state = state; } - async updateReceiveHistoryState( - mintUrl: string, - operationId: string, - state: ReceiveHistoryState, - ): Promise { - const entry = await this.getReceiveHistoryEntry(mintUrl, operationId); - if (!entry) { - throw new Error(`Receive history entry not found for operationId: ${operationId}`); + private dedupeLegacyEntries(operationEntries: HistoryEntry[]): LegacyHistoryEntry[] { + const operationKeys = new Set(); + const quoteKeys = new Set(); + + for (const entry of operationEntries) { + if (entry.source !== 'operation') continue; + operationKeys.add(this.operationKey(entry.type, entry.operationId)); + if ((entry.type === 'mint' || entry.type === 'melt') && entry.quoteId) { + quoteKeys.add(this.quoteKey(entry.type, entry.mintUrl, entry.quoteId)); + } } - entry.state = state; - } - async deleteHistoryEntry(mintUrl: string, quoteId: string): Promise { - for (let i = this.entries.length - 1; i >= 0; i--) { - const e = this.entries[i]; - if (!e) continue; + return this.legacyEntries.filter((entry) => { if ( - (e.type === 'mint' || e.type === 'melt') && - e.mintUrl === mintUrl && - e.quoteId === quoteId + entry.operationId && + operationKeys.has(this.operationKey(entry.type, entry.operationId)) ) { - this.entries.splice(i, 1); + return false; } - } + + if ( + (entry.type === 'mint' || entry.type === 'melt') && + entry.quoteId && + quoteKeys.has(this.quoteKey(entry.type, entry.mintUrl, entry.quoteId)) + ) { + return false; + } + + return true; + }); + } + + private operationKey(type: HistoryType, operationId: string): string { + return `${type}:${operationId}`; + } + + private quoteKey(type: 'mint' | 'melt', mintUrl: string, quoteId: string): string { + return `${type}:${mintUrl}:${quoteId}`; } } diff --git a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts index ca17b6c6..1dfa047b 100644 --- a/packages/core/repositories/memory/MemoryMeltOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMeltOperationRepository.ts @@ -67,6 +67,10 @@ export class MemoryMeltOperationRepository implements MeltOperationRepository { return results; } + async getAll(): Promise { + return Array.from(this.operations.values(), (operation) => ({ ...operation })); + } + async delete(id: string): Promise { this.operations.delete(id); } diff --git a/packages/core/repositories/memory/MemoryMintOperationRepository.ts b/packages/core/repositories/memory/MemoryMintOperationRepository.ts index 1dd440d9..1013e8a8 100644 --- a/packages/core/repositories/memory/MemoryMintOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryMintOperationRepository.ts @@ -67,6 +67,10 @@ export class MemoryMintOperationRepository implements MintOperationRepository { return results; } + async getAll(): Promise { + return Array.from(this.operations.values(), (operation) => ({ ...operation })); + } + async delete(id: string): Promise { this.operations.delete(id); } diff --git a/packages/core/repositories/memory/MemoryReceiveOperationRepository.ts b/packages/core/repositories/memory/MemoryReceiveOperationRepository.ts index 1ed7e7d7..3314277e 100644 --- a/packages/core/repositories/memory/MemoryReceiveOperationRepository.ts +++ b/packages/core/repositories/memory/MemoryReceiveOperationRepository.ts @@ -56,6 +56,10 @@ export class MemoryReceiveOperationRepository implements ReceiveOperationReposit return results; } + async getAll(): Promise { + return Array.from(this.operations.values(), (operation) => ({ ...operation })); + } + async delete(id: string): Promise { this.operations.delete(id); } diff --git a/packages/core/repositories/memory/MemoryRepositories.ts b/packages/core/repositories/memory/MemoryRepositories.ts index 63e98191..cd7751c4 100644 --- a/packages/core/repositories/memory/MemoryRepositories.ts +++ b/packages/core/repositories/memory/MemoryRepositories.ts @@ -1,7 +1,7 @@ import type { AuthSessionRepository, CounterRepository, - HistoryRepository, + HistoryProjectionRepository, KeyRingRepository, KeysetRepository, MeltOperationRepository, @@ -37,7 +37,7 @@ export class MemoryRepositories implements Repositories { proofRepository: ProofRepository; mintQuoteRepository: MintQuoteRepository; meltQuoteRepository: MeltQuoteRepository; - historyRepository: HistoryRepository; + historyRepository: HistoryProjectionRepository; sendOperationRepository: SendOperationRepository; meltOperationRepository: MeltOperationRepository; authSessionRepository: AuthSessionRepository; @@ -50,14 +50,24 @@ export class MemoryRepositories implements Repositories { this.counterRepository = new MemoryCounterRepository(); this.keysetRepository = new MemoryKeysetRepository(); this.proofRepository = new MemoryProofRepository(); + const sendOperationRepository = new MemorySendOperationRepository(); + const meltOperationRepository = new MemoryMeltOperationRepository(); + const mintOperationRepository = new MemoryMintOperationRepository(); + const receiveOperationRepository = new MemoryReceiveOperationRepository(); + + this.sendOperationRepository = sendOperationRepository; + this.meltOperationRepository = meltOperationRepository; + this.mintOperationRepository = mintOperationRepository; + this.receiveOperationRepository = receiveOperationRepository; this.mintQuoteRepository = new MemoryMintQuoteRepository(); this.meltQuoteRepository = new MemoryMeltQuoteRepository(); - this.historyRepository = new MemoryHistoryRepository(); - this.sendOperationRepository = new MemorySendOperationRepository(); - this.meltOperationRepository = new MemoryMeltOperationRepository(); + this.historyRepository = new MemoryHistoryRepository({ + sendOperationRepository, + meltOperationRepository, + mintOperationRepository, + receiveOperationRepository, + }); this.authSessionRepository = new MemoryAuthSessionRepository(); - this.mintOperationRepository = new MemoryMintOperationRepository(); - this.receiveOperationRepository = new MemoryReceiveOperationRepository(); } async init(): Promise { diff --git a/packages/core/repositories/memory/MemorySendOperationRepository.ts b/packages/core/repositories/memory/MemorySendOperationRepository.ts index 24c21bab..1718bbdf 100644 --- a/packages/core/repositories/memory/MemorySendOperationRepository.ts +++ b/packages/core/repositories/memory/MemorySendOperationRepository.ts @@ -53,6 +53,10 @@ export class MemorySendOperationRepository implements SendOperationRepository { return results; } + async getAll(): Promise { + return Array.from(this.operations.values(), (operation) => ({ ...operation })); + } + async delete(id: string): Promise { this.operations.delete(id); } diff --git a/packages/core/services/HistoryService.ts b/packages/core/services/HistoryService.ts index 6bfe720f..e11bd507 100644 --- a/packages/core/services/HistoryService.ts +++ b/packages/core/services/HistoryService.ts @@ -1,84 +1,78 @@ -import type { MeltQuoteBolt11Response, MeltQuoteState, Token } from '@cashu/cashu-ts'; -import type { HistoryRepository } from '../repositories'; +import type { HistoryProjectionRepository } from '../repositories'; import { EventBus } from '../events/EventBus'; import type { CoreEvents } from '../events/types'; -import type { - HistoryEntry, - MeltHistoryEntry, - MintHistoryEntry, - ReceiveHistoryEntry, - ReceiveHistoryState, - SendHistoryEntry, - SendHistoryState, +import type { HistoryEntry, OperationHistoryEntry } from '@core/models/History'; +import { + projectMeltOperation, + projectMintOperation, + projectReceiveOperation, + projectSendOperation, } from '@core/models/History'; -import type { PreparedOrLaterOperation } from '@core/operations/melt'; -import type { PendingMintOperation } from '@core/operations/mint'; -import type { MintQuoteState } from '@core/models/MintQuoteState'; import type { Logger } from '@core/logging'; +import type { MeltOperation } from '@core/operations/melt'; +import type { MintOperation } from '@core/operations/mint'; import type { ReceiveOperation } from '@core/operations/receive/ReceiveOperation'; import type { SendOperation } from '@core/operations/send/SendOperation'; +import type { Token } from '@cashu/cashu-ts'; export class HistoryService { - private readonly historyRepository: HistoryRepository; + private readonly historyRepository: HistoryProjectionRepository; private readonly logger?: Logger; private readonly eventBus: EventBus; constructor( - historyRepository: HistoryRepository, + historyRepository: HistoryProjectionRepository, eventBus: EventBus, logger?: Logger, ) { this.historyRepository = historyRepository; this.logger = logger; this.eventBus = eventBus; - this.eventBus.on('mint-op:pending', ({ mintUrl, operation }) => { - if (operation.state !== 'pending') return; - return this.handleMintOperationPending(mintUrl, operation as PendingMintOperation); + + this.eventBus.on('send:prepared', ({ mintUrl, operation }) => { + return this.emitProjectedSend(mintUrl, operation); }); - this.eventBus.on('mint-op:quote-state-changed', ({ mintUrl, operationId, quoteId, state }) => { - return this.handleMintOperationQuoteStateChanged(mintUrl, operationId, quoteId, state); + this.eventBus.on('send:pending', ({ mintUrl, operation, token }) => { + return this.emitProjectedSend(mintUrl, this.withSendToken(operation, token)); }); - this.eventBus.on('melt-quote:created', ({ mintUrl, quoteId, quote }) => { - return this.handleMeltQuoteCreated(mintUrl, quoteId, quote); + this.eventBus.on('send:finalized', ({ mintUrl, operation }) => { + return this.emitProjectedSend(mintUrl, operation); }); - this.eventBus.on('melt-quote:state-changed', ({ mintUrl, quoteId, state }) => { - return this.handleMeltQuoteStateChanged(mintUrl, quoteId, state); + this.eventBus.on('send:rolled-back', ({ mintUrl, operation }) => { + return this.emitProjectedSend(mintUrl, operation); }); + this.eventBus.on('melt-op:prepared', ({ mintUrl, operation }) => { - if (operation.state !== 'prepared') return; - return this.handleMeltOperationUpdated(mintUrl, operation, 'UNPAID'); + return this.emitProjectedMelt(mintUrl, operation); }); this.eventBus.on('melt-op:pending', ({ mintUrl, operation }) => { - if (operation.state !== 'pending') return; - return this.handleMeltOperationUpdated(mintUrl, operation, 'PENDING'); + return this.emitProjectedMelt(mintUrl, operation); }); this.eventBus.on('melt-op:finalized', ({ mintUrl, operation }) => { - if (operation.state !== 'finalized') return; - return this.handleMeltOperationUpdated(mintUrl, operation, 'PAID'); + return this.emitProjectedMelt(mintUrl, operation); }); this.eventBus.on('melt-op:rolled-back', ({ mintUrl, operation }) => { - if (operation.state !== 'rolled_back') return; - return this.handleMeltOperationRolledBack(mintUrl, operation); + return this.emitProjectedMelt(mintUrl, operation); }); - this.eventBus.on('send:prepared', ({ mintUrl, operationId, operation }) => { - return this.handleSendPrepared(mintUrl, operationId, operation); + + this.eventBus.on('mint-op:pending', ({ mintUrl, operation }) => { + return this.emitProjectedMint(mintUrl, operation); }); - this.eventBus.on('send:pending', ({ mintUrl, operationId, token }) => { - return this.handleSendPending(mintUrl, operationId, token); + this.eventBus.on('mint-op:executing', ({ mintUrl, operation }) => { + return this.emitProjectedMint(mintUrl, operation); }); - this.eventBus.on('send:finalized', ({ mintUrl, operationId }) => { - return this.handleSendStateChanged(mintUrl, operationId, 'finalized'); + this.eventBus.on('mint-op:finalized', ({ mintUrl, operation }) => { + return this.emitProjectedMint(mintUrl, operation); }); - this.eventBus.on('send:rolled-back', ({ mintUrl, operationId }) => { - return this.handleSendStateChanged(mintUrl, operationId, 'rolledBack'); + this.eventBus.on('mint-op:quote-state-changed', ({ mintUrl, operation }) => { + return this.emitProjectedMint(mintUrl, operation); }); + this.eventBus.on('receive-op:finalized', ({ mintUrl, operation }) => { - if (operation.state !== 'finalized') return; - return this.handleReceiveOperationUpdated(mintUrl, operation, 'finalized'); + return this.emitProjectedReceive(mintUrl, operation); }); this.eventBus.on('receive-op:rolled-back', ({ mintUrl, operation }) => { - if (operation.state !== 'rolled_back') return; - return this.handleReceiveOperationUpdated(mintUrl, operation, 'rolledBack'); + return this.emitProjectedReceive(mintUrl, operation); }); } @@ -92,7 +86,7 @@ export class HistoryService { /** * Get the operationId for a send history entry. - * @throws Error if entry not found or is not a send entry + * @throws Error if entry not found, is not a send entry, or has no operation id */ async getOperationIdFromHistoryEntry(historyId: string): Promise { const entry = await this.historyRepository.getHistoryEntryById(historyId); @@ -105,290 +99,58 @@ export class HistoryService { throw new Error(`History entry ${historyId} is not a send entry`); } - return entry.operationId; - } - - async handleSendPrepared(mintUrl: string, operationId: string, operation: SendOperation) { - const entry: Omit = { - type: 'send', - createdAt: Date.now(), - unit: 'sat', // TODO: get unit from operation/mint - amount: operation.amount, - mintUrl, - operationId, - state: 'prepared', - }; - try { - const entryRes = await this.historyRepository.addHistoryEntry(entry); - await this.handleHistoryUpdated(mintUrl, entryRes); - } catch (err) { - this.logger?.error('Failed to add send prepared history entry', { - mintUrl, - operationId, - err, - }); + if (!entry.operationId) { + throw new Error(`History entry ${historyId} is not backed by an operation`); } - } - async handleSendPending(mintUrl: string, operationId: string, token: Token) { - try { - const entry = await this.historyRepository.getSendHistoryEntry(mintUrl, operationId); - if (!entry) { - this.logger?.error('Send pending history entry not found', { - mintUrl, - operationId, - }); - return; - } - entry.state = 'pending'; - entry.token = token; - entry.unit = token.unit || 'sat'; - await this.historyRepository.updateHistoryEntry(entry); - await this.handleHistoryUpdated(mintUrl, entry); - } catch (err) { - this.logger?.error('Failed to update send pending history entry', { - mintUrl, - operationId, - err, - }); - } - } - - async handleSendStateChanged(mintUrl: string, operationId: string, state: SendHistoryState) { - try { - await this.historyRepository.updateSendHistoryState(mintUrl, operationId, state); - const entry = await this.historyRepository.getSendHistoryEntry(mintUrl, operationId); - if (entry) { - await this.handleHistoryUpdated(mintUrl, entry); - } - } catch (err) { - this.logger?.error('Failed to update send state history entry', { - mintUrl, - operationId, - state, - err, - }); - } + return entry.operationId; } - async handleReceiveOperationUpdated( - mintUrl: string, - operation: ReceiveOperation, - state: ReceiveHistoryState, - ) { - try { - const token = - state === 'finalized' - ? { - mint: operation.mintUrl, - proofs: operation.inputProofs, - unit: operation.unit || 'sat', - } - : undefined; - const existing = await this.historyRepository.getReceiveHistoryEntry(mintUrl, operation.id); - if (existing) { - existing.amount = operation.amount; - existing.unit = operation.unit || existing.unit || 'sat'; - existing.state = state; - if (token) { - existing.token = token; - } - await this.historyRepository.updateHistoryEntry(existing); - await this.handleHistoryUpdated(mintUrl, existing); - return; - } - - const entryPayload: Omit = { - type: 'receive', - createdAt: Date.now(), - unit: operation.unit || 'sat', - amount: operation.amount, - mintUrl, - operationId: operation.id, - state, - token, - }; - const entry = await this.historyRepository.addHistoryEntry(entryPayload); - await this.handleHistoryUpdated(mintUrl, entry); - } catch (err) { - this.logger?.error('Failed to update receive history entry', { - mintUrl, - operationId: operation.id, - state, - err, - }); - } + private async emitProjectedSend(mintUrl: string, operation: SendOperation): Promise { + await this.emitProjectedEntry(mintUrl, projectSendOperation(operation), 'send', operation.id); } - async handleMintOperationQuoteStateChanged( - mintUrl: string, - operationId: string, - quoteId: string, - state: MintQuoteState, - ) { - try { - const entry = await this.historyRepository.getMintHistoryEntry(mintUrl, quoteId); - if (!entry) { - this.logger?.error('Mint operation quote state changed history entry not found', { - mintUrl, - quoteId, - operationId, - }); - return; - } - entry.operationId = operationId; - entry.state = state; - await this.historyRepository.updateHistoryEntry(entry); - await this.handleHistoryUpdated(mintUrl, { ...entry, state }); - } catch (err) { - this.logger?.error('Failed to update mint operation history state', { - mintUrl, - quoteId, - operationId, - err, - }); - } + private async emitProjectedMelt(mintUrl: string, operation: MeltOperation): Promise { + await this.emitProjectedEntry(mintUrl, projectMeltOperation(operation), 'melt', operation.id); } - async handleMeltQuoteStateChanged(mintUrl: string, quoteId: string, state: MeltQuoteState) { - try { - const entry = await this.historyRepository.getMeltHistoryEntry(mintUrl, quoteId); - if (!entry) { - this.logger?.error('Melt quote state changed history entry not found', { - mintUrl, - quoteId, - }); - return; - } - entry.state = state; - await this.historyRepository.updateHistoryEntry(entry); - await this.handleHistoryUpdated(mintUrl, { ...entry, state }); - } catch (err) { - this.logger?.error('Failed to add melt quote state changed history entry', { - mintUrl, - quoteId, - err, - }); - } + private async emitProjectedMint(mintUrl: string, operation: MintOperation): Promise { + await this.emitProjectedEntry(mintUrl, projectMintOperation(operation), 'mint', operation.id); } - async handleMeltQuoteCreated(mintUrl: string, quoteId: string, quote: MeltQuoteBolt11Response) { - const entry: Omit = { - type: 'melt', - createdAt: Date.now(), - unit: quote.unit, - amount: quote.amount, + private async emitProjectedReceive(mintUrl: string, operation: ReceiveOperation): Promise { + await this.emitProjectedEntry( mintUrl, - quoteId, - state: quote.state, - }; - try { - const newEntry = await this.historyRepository.addHistoryEntry(entry); - await this.handleHistoryUpdated(mintUrl, newEntry); - } catch (err) { - this.logger?.error('Failed to add melt quote created history entry', { - mintUrl, - quoteId, - err, - }); - } + projectReceiveOperation(operation), + 'receive', + operation.id, + ); } - async handleMeltOperationUpdated( + private async emitProjectedEntry( mintUrl: string, - operation: PreparedOrLaterOperation, - state: MeltQuoteState, - ) { - const existing = await this.historyRepository.getMeltHistoryEntry(mintUrl, operation.quoteId); - const entry: Omit = { - type: 'melt', - mintUrl, - operationId: operation.id, - quoteId: operation.quoteId, - amount: operation.amount, - state, - createdAt: operation.createdAt, - unit: operation.unit || existing?.unit || 'sat', - }; - - try { - if (existing) { - existing.amount = entry.amount; - existing.operationId = entry.operationId; - existing.state = entry.state; - existing.unit = entry.unit; - const updated = await this.historyRepository.updateHistoryEntry(existing); - await this.handleHistoryUpdated(mintUrl, updated); - return; - } - - const created = await this.historyRepository.addHistoryEntry(entry); - await this.handleHistoryUpdated(mintUrl, created); - } catch (err) { - this.logger?.error('Failed to upsert melt operation history entry', { - mintUrl, - quoteId: operation.quoteId, - operationId: operation.id, - state, - err, - }); - } - } - - async handleMeltOperationRolledBack(mintUrl: string, operation: PreparedOrLaterOperation) { - await this.handleMeltOperationUpdated(mintUrl, operation, 'UNPAID'); - } - - async handleMintOperationPending(mintUrl: string, operation: PendingMintOperation) { - const entry: Omit = { - type: 'mint', - mintUrl, - operationId: operation.id, - unit: operation.unit, - paymentRequest: operation.request, - quoteId: operation.quoteId, - state: operation.lastObservedRemoteState ?? 'UNPAID', - createdAt: operation.createdAt, - amount: operation.amount, - }; + entry: OperationHistoryEntry | null, + type: OperationHistoryEntry['type'], + operationId: string, + ): Promise { + if (!entry) return; try { - const existing = await this.historyRepository.getMintHistoryEntry(mintUrl, operation.quoteId); - if (existing) { - existing.operationId = entry.operationId; - existing.unit = entry.unit; - existing.paymentRequest = entry.paymentRequest; - existing.state = entry.state; - existing.amount = entry.amount; - const updated = await this.historyRepository.updateHistoryEntry(existing); - await this.handleHistoryUpdated(mintUrl, updated); - return; - } - - const created = await this.historyRepository.addHistoryEntry(entry); - await this.handleHistoryUpdated(mintUrl, created); - this.logger?.debug('Added history entry for pending mint operation', { - mintUrl, - quoteId: operation.quoteId, - operationId: operation.id, - state: entry.state, - }); + await this.eventBus.emit('history:updated', { mintUrl, entry: { ...entry } }); } catch (err) { - this.logger?.error('Failed to add pending mint operation history entry', { + this.logger?.error('Failed to emit history projection', { mintUrl, - quoteId: operation.quoteId, - operationId: operation.id, + type, + operationId, err, }); } } - async handleHistoryUpdated(mintUrl: string, entry: HistoryEntry) { - try { - // Emit a shallow copy to prevent mutation after emission - await this.eventBus.emit('history:updated', { mintUrl, entry: { ...entry } }); - } catch (err) { - this.logger?.error('Failed to emit history entry', { mintUrl, entry, err }); + private withSendToken(operation: SendOperation, token: Token): SendOperation { + if (operation.state === 'pending' || operation.state === 'finalized') { + return { ...operation, token } as SendOperation; } + return operation; } } diff --git a/packages/core/test/unit/HistoryApi.test.ts b/packages/core/test/unit/HistoryApi.test.ts index 3827e466..7bee9d0e 100644 --- a/packages/core/test/unit/HistoryApi.test.ts +++ b/packages/core/test/unit/HistoryApi.test.ts @@ -22,14 +22,16 @@ describe('HistoryApi', () => { historyService.getHistoryEntryById as unknown as ReturnType ).mockResolvedValueOnce({ id: 'history-1', + source: 'operation', type: 'melt', mintUrl: 'https://mint.test', quoteId: 'quote-1', operationId: 'operation-1', amount: Amount.from(10), - state: 'UNPAID', + state: 'prepared', unit: 'sat', createdAt: Date.now(), + updatedAt: Date.now(), } as HistoryEntry); await expect(api.getOperationIdForHistoryEntry('history-1')).resolves.toBe('operation-1'); @@ -46,6 +48,7 @@ describe('HistoryApi', () => { historyService.getHistoryEntryById as unknown as ReturnType ).mockResolvedValueOnce({ id: 'history-3', + source: 'operation', type: 'send', mintUrl: 'https://mint.test', operationId: ' ', @@ -53,6 +56,7 @@ describe('HistoryApi', () => { state: 'pending', unit: 'sat', createdAt: Date.now(), + updatedAt: Date.now(), } as HistoryEntry); await expect(api.getOperationIdForHistoryEntry('history-3')).resolves.toBeNull(); diff --git a/packages/core/test/unit/HistoryService.test.ts b/packages/core/test/unit/HistoryService.test.ts index 19d3c0dc..510946a2 100644 --- a/packages/core/test/unit/HistoryService.test.ts +++ b/packages/core/test/unit/HistoryService.test.ts @@ -1,590 +1,320 @@ import { Amount } from '@cashu/cashu-ts'; -import { describe, it, beforeEach, expect } from 'bun:test'; -import { HistoryService } from '../../services/HistoryService'; +import { beforeEach, describe, expect, it } from 'bun:test'; import { EventBus } from '../../events/EventBus'; import type { CoreEvents } from '../../events/types'; -import type { HistoryRepository } from '../../repositories'; -import type { - HistoryEntry, - MeltHistoryEntry, - MintHistoryEntry, - ReceiveHistoryEntry, - SendHistoryEntry, -} from '../../models/History'; -import type { - FinalizedMeltOperation, - PendingMeltOperation, - PreparedMeltOperation, - RolledBackMeltOperation, -} from '../../operations/melt'; -import type { PendingMintOperation } from '../../operations/mint'; +import type { HistoryEntry, HistoryRepository, LegacyHistoryEntry } from '../..'; +import { HistoryService } from '../../services/HistoryService'; +import type { FinalizedMeltOperation, PreparedMeltOperation } from '../../operations/melt'; +import type { FailedMintOperation } from '../../operations/mint'; import type { FinalizedReceiveOperation, PreparedReceiveOperation, RolledBackReceiveOperation, } from '../../operations/receive/ReceiveOperation'; +import type { + PendingSendOperation, + PreparedSendOperation, +} from '../../operations/send/SendOperation'; describe('HistoryService', () => { let service: HistoryService; - let mockRepo: HistoryRepository; let eventBus: EventBus; - let historyEntries: Map; let historyUpdateEvents: Array<{ mintUrl: string; entry: HistoryEntry }>; - const receiveProofs = [{ id: 'keyset-1', amount: Amount.from(42), secret: 'secret-1', C: 'C-1' }]; - const makePendingOperation = ( - quoteId: string, - overrides: Partial = {}, - ): PendingMintOperation => - ({ - id: `mint-op-${quoteId}`, - state: 'pending', - mintUrl: 'https://mint.test', - method: 'bolt11', - methodData: {}, - amount: Amount.from(1000), - unit: 'sat', - quoteId, - request: `request-${quoteId}`, - expiry: Math.floor(Date.now() / 1000) + 3600, - outputData: { keep: [], send: [] }, - createdAt: Date.now(), - updatedAt: Date.now(), - lastObservedRemoteState: 'UNPAID', - lastObservedRemoteStateAt: Date.now(), - ...overrides, - }) as PendingMintOperation; - - const makePreparedMeltOperation = ( - quoteId: string, - overrides: Partial = {}, - ): PreparedMeltOperation => - ({ - id: `melt-op-${quoteId}`, - state: 'prepared', - mintUrl: 'https://mint.test', - method: 'bolt11', - methodData: { invoice: `lnbc-${quoteId}` }, - amount: Amount.from(900), - unit: 'sat', - fee_reserve: Amount.from(10), - quoteId, - swap_fee: Amount.from(0), - inputAmount: Amount.from(910), - inputProofSecrets: ['proof-secret-1'], - changeOutputData: { keep: [], send: [] }, - needsSwap: false, - createdAt: Date.now(), - updatedAt: Date.now(), - ...overrides, - }) as PreparedMeltOperation; - - const makePendingMeltOperation = ( - quoteId: string, - overrides: Partial = {}, - ): PendingMeltOperation => - ({ - ...makePreparedMeltOperation(quoteId), - state: 'pending', - ...overrides, - }) as PendingMeltOperation; - - const makeFinalizedMeltOperation = ( - quoteId: string, - overrides: Partial = {}, - ): FinalizedMeltOperation => - ({ - ...makePreparedMeltOperation(quoteId), - state: 'finalized', - changeAmount: Amount.from(0), - effectiveFee: Amount.from(10), - ...overrides, - }) as FinalizedMeltOperation; - - const makeRolledBackMeltOperation = ( - quoteId: string, - overrides: Partial = {}, - ): RolledBackMeltOperation => - ({ - ...makePreparedMeltOperation(quoteId), - state: 'rolled_back', - error: 'Rolled back', - ...overrides, - }) as RolledBackMeltOperation; - - const makePreparedReceiveOperation = ( - operationId: string, - overrides: Partial = {}, - ): PreparedReceiveOperation => - ({ - id: operationId, - state: 'prepared', - mintUrl: 'https://mint.test', - unit: 'sat', - amount: Amount.from(42), - fee: Amount.from(1), - outputData: { keep: [], send: [] }, - inputProofs: receiveProofs, - createdAt: Date.now(), - updatedAt: Date.now(), - ...overrides, - }) as PreparedReceiveOperation; - - const makeFinalizedReceiveOperation = ( - operationId: string, - overrides: Partial = {}, - ): FinalizedReceiveOperation => - ({ - ...makePreparedReceiveOperation(operationId), - state: 'finalized', - ...overrides, - }) as FinalizedReceiveOperation; + let repositoryEntries: Map; - const makeRolledBackReceiveOperation = ( - operationId: string, - overrides: Partial = {}, - ): RolledBackReceiveOperation => - ({ - ...makePreparedReceiveOperation(operationId), - state: 'rolled_back', - error: 'Rolled back', - ...overrides, - }) as RolledBackReceiveOperation; + const mintUrl = 'https://mint.test'; + const receiveProofs = [{ id: 'keyset-1', amount: Amount.from(42), secret: 'secret-1', C: 'C-1' }]; beforeEach(() => { - historyEntries = new Map(); + repositoryEntries = new Map(); historyUpdateEvents = []; - - mockRepo = { - async addHistoryEntry(entry: Omit): Promise { - const id = Math.random().toString(36).substring(7); - const fullEntry = { ...entry, id } as HistoryEntry; - historyEntries.set(id, fullEntry); - return fullEntry; - }, - async getMintHistoryEntry( - mintUrl: string, - quoteId: string, - ): Promise { - for (const entry of historyEntries.values()) { - if (entry.type === 'mint' && entry.mintUrl === mintUrl && entry.quoteId === quoteId) { - return entry as MintHistoryEntry; - } - } - return null; - }, - async getMeltHistoryEntry( - mintUrl: string, - quoteId: string, - ): Promise { - for (const entry of historyEntries.values()) { - if (entry.type === 'melt' && entry.mintUrl === mintUrl && entry.quoteId === quoteId) { - return entry as MeltHistoryEntry; - } - } - return null; - }, - async getPaginatedHistoryEntries(): Promise { - return Array.from(historyEntries.values()); - }, - async getSendHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - for (const entry of historyEntries.values()) { - if ( - entry.type === 'send' && - entry.mintUrl === mintUrl && - entry.operationId === operationId - ) { - return entry; - } - } - return null; - }, - async getReceiveHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - for (const entry of historyEntries.values()) { - if ( - entry.type === 'receive' && - entry.mintUrl === mintUrl && - entry.operationId === operationId - ) { - return entry; - } - } - return null; - }, - async getHistoryEntryById(id: string): Promise { - return historyEntries.get(id) ?? null; - }, - async updateHistoryEntry(entry: HistoryEntry): Promise { - historyEntries.set(entry.id, entry); - return entry; - }, - async updateSendHistoryState(): Promise {}, - async updateReceiveHistoryState(): Promise {}, - async deleteHistoryEntry(mintUrl: string, quoteId: string): Promise { - for (const [id, entry] of historyEntries.entries()) { - if ( - (entry.type === 'mint' || entry.type === 'melt') && - entry.mintUrl === mintUrl && - entry.quoteId === quoteId - ) { - historyEntries.delete(id); - } - } - }, - } as HistoryRepository; - eventBus = new EventBus(); eventBus.on('history:updated', (payload) => { historyUpdateEvents.push(payload); }); - service = new HistoryService(mockRepo, eventBus); - }); + const historyRepository: HistoryRepository = { + async getPaginatedHistoryEntries(limit: number, offset: number): Promise { + return Array.from(repositoryEntries.values()).slice(offset, offset + limit); + }, + async getHistoryEntryById(id: string): Promise { + return repositoryEntries.get(id) ?? null; + }, + }; - describe('mint operations', () => { - it('creates history entry for mint-op:pending', async () => { - const operation = makePendingOperation('pending-quote', { - amount: Amount.from(1000), - request: 'lnbc1000...', - lastObservedRemoteState: 'UNPAID', - }); + service = new HistoryService(historyRepository, eventBus); + }); - await eventBus.emit('mint-op:pending', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - }); + it('emits deterministic operation-backed send entries from send lifecycle events', async () => { + const prepared = makePreparedSendOperation('send-op-1'); + const pending = { + ...prepared, + state: 'pending', + token: { mint: mintUrl, proofs: receiveProofs, unit: 'usd' }, + } as PendingSendOperation; - expect(historyEntries.size).toBe(1); - const entry = Array.from(historyEntries.values())[0] as MintHistoryEntry; - expect(entry.type).toBe('mint'); - expect(entry.mintUrl).toBe(operation.mintUrl); - expect(entry.quoteId).toBe(operation.quoteId); - expect(entry.amount).toEqual(operation.amount); - expect(entry.state).toBe('UNPAID'); - expect(entry.unit).toBe(operation.unit); - expect(entry.paymentRequest).toBe(operation.request); - expect(entry.operationId).toBe(operation.id); - expect(historyUpdateEvents.length).toBe(1); + await eventBus.emit('send:prepared', { + mintUrl, + operationId: prepared.id, + operation: prepared, }); - - it('updates existing history entry on mint-op:quote-state-changed', async () => { - const operation = makePendingOperation('stateful-quote', { - amount: Amount.from(500), - request: 'lnbc500...', - lastObservedRemoteState: 'UNPAID', - }); - - await mockRepo.addHistoryEntry({ - type: 'mint', - mintUrl: operation.mintUrl, - quoteId: operation.quoteId, - amount: operation.amount, - state: 'UNPAID', - unit: operation.unit, - paymentRequest: operation.request, - createdAt: operation.createdAt, - } as Omit); - - await eventBus.emit('mint-op:quote-state-changed', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - quoteId: operation.quoteId, - state: 'PAID', - }); - - const entry = Array.from(historyEntries.values())[0] as MintHistoryEntry; - expect(entry.operationId).toBe(operation.id); - expect(entry.state).toBe('PAID'); - expect(historyUpdateEvents.length).toBe(1); - expect(historyUpdateEvents[0]?.entry.type).toBe('mint'); + await eventBus.emit('send:pending', { + mintUrl, + operationId: pending.id, + operation: pending, + token: pending.token!, }); - it('updates an existing history entry instead of creating a duplicate pending entry', async () => { - const operation = makePendingOperation('existing-quote', { - amount: Amount.from(750), - request: 'lnbc750...', - lastObservedRemoteState: 'PAID', - }); - - await mockRepo.addHistoryEntry({ - type: 'mint', - mintUrl: operation.mintUrl, - quoteId: operation.quoteId, - amount: Amount.from(10), - state: 'UNPAID', - unit: operation.unit, - paymentRequest: 'old-request', - createdAt: operation.createdAt, - } as Omit); - - await eventBus.emit('mint-op:pending', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - }); - - expect(historyEntries.size).toBe(1); - const entry = Array.from(historyEntries.values())[0] as MintHistoryEntry; - expect(entry.amount).toEqual(operation.amount); - expect(entry.operationId).toBe(operation.id); - expect(entry.paymentRequest).toBe(operation.request); - expect(entry.state).toBe('PAID'); - expect(historyUpdateEvents.length).toBe(1); + expect(historyUpdateEvents).toHaveLength(2); + expect(historyUpdateEvents[0]?.entry).toMatchObject({ + id: `send:${prepared.id}`, + source: 'operation', + type: 'send', + operationId: prepared.id, + state: 'prepared', + unit: 'sat', + }); + expect(historyUpdateEvents[1]?.entry).toMatchObject({ + id: `send:${pending.id}`, + source: 'operation', + type: 'send', + operationId: pending.id, + state: 'pending', + unit: 'usd', + token: pending.token, }); }); - describe('melt operations', () => { - it('creates history entry for melt-op:prepared', async () => { - const operation = makePreparedMeltOperation('melt-prepared', { amount: Amount.from(250) }); - - await eventBus.emit('melt-op:prepared', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - }); + it('projects melt operation states without mapping them to quote states', async () => { + const prepared = makePreparedMeltOperation('melt-op-1', 'quote-1'); + const finalized = { + ...prepared, + state: 'finalized', + changeAmount: Amount.from(0), + effectiveFee: Amount.from(10), + } as FinalizedMeltOperation; - expect(historyEntries.size).toBe(1); - const entry = Array.from(historyEntries.values())[0] as MeltHistoryEntry; - expect(entry.type).toBe('melt'); - expect(entry.mintUrl).toBe(operation.mintUrl); - expect(entry.quoteId).toBe(operation.quoteId); - expect(entry.amount).toEqual(operation.amount); - expect(entry.operationId).toBe(operation.id); - expect(entry.state).toBe('UNPAID'); - expect(entry.unit).toBe('sat'); - expect(historyUpdateEvents.length).toBe(1); + await eventBus.emit('melt-op:prepared', { + mintUrl, + operationId: prepared.id, + operation: prepared, }); - - it('preserves non-sat unit when creating melt history from an operation event', async () => { - const operation = makePreparedMeltOperation('melt-usd', { - amount: Amount.from(250), - unit: 'usd', - }); - - await eventBus.emit('melt-op:prepared', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - }); - - const entry = Array.from(historyEntries.values())[0] as MeltHistoryEntry; - expect(entry.unit).toBe('usd'); + await eventBus.emit('melt-op:finalized', { + mintUrl, + operationId: finalized.id, + operation: finalized, }); - it('updates an existing melt history entry on melt-op:pending', async () => { - const operation = makePendingMeltOperation('melt-pending', { amount: Amount.from(275) }); - - await mockRepo.addHistoryEntry({ - type: 'melt', - mintUrl: operation.mintUrl, - quoteId: operation.quoteId, - amount: Amount.from(100), - state: 'UNPAID', - unit: 'sat', - createdAt: operation.createdAt, - } as Omit); - - await eventBus.emit('melt-op:pending', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - }); - - expect(historyEntries.size).toBe(1); - const entry = Array.from(historyEntries.values())[0] as MeltHistoryEntry; - expect(entry.amount).toEqual(operation.amount); - expect(entry.operationId).toBe(operation.id); - expect(entry.state).toBe('PENDING'); - expect(historyUpdateEvents.length).toBe(1); - expect(historyUpdateEvents[0]?.entry.type).toBe('melt'); + expect(historyUpdateEvents[0]?.entry).toMatchObject({ + id: `melt:${prepared.id}`, + source: 'operation', + type: 'melt', + quoteId: prepared.quoteId, + state: 'prepared', }); - - it('preserves an existing non-sat unit when the operation payload omits it', async () => { - const operation = makePendingMeltOperation('melt-pending-usd', { - amount: Amount.from(275), - unit: undefined as unknown as string, - }); - - await mockRepo.addHistoryEntry({ - type: 'melt', - mintUrl: operation.mintUrl, - quoteId: operation.quoteId, - amount: Amount.from(100), - state: 'UNPAID', - unit: 'usd', - createdAt: operation.createdAt, - } as Omit); - - await eventBus.emit('melt-op:pending', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - }); - - const entry = Array.from(historyEntries.values())[0] as MeltHistoryEntry; - expect(entry.unit).toBe('usd'); + expect(historyUpdateEvents[1]?.entry).toMatchObject({ + id: `melt:${finalized.id}`, + source: 'operation', + type: 'melt', + quoteId: finalized.quoteId, + state: 'finalized', }); + }); - it('creates history entry for immediate melt-op:finalized results', async () => { - const operation = makeFinalizedMeltOperation('melt-finalized', { amount: Amount.from(300) }); - - await eventBus.emit('melt-op:finalized', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - }); - - expect(historyEntries.size).toBe(1); - const entry = Array.from(historyEntries.values())[0] as MeltHistoryEntry; - expect(entry.quoteId).toBe(operation.quoteId); - expect(entry.amount).toEqual(operation.amount); - expect(entry.operationId).toBe(operation.id); - expect(entry.state).toBe('PAID'); - expect(historyUpdateEvents.length).toBe(1); + it('does not expose melt failed entries until persistence supports that state', async () => { + const failed = { + ...makePreparedMeltOperation('melt-op-failed', 'quote-failed'), + state: 'failed', + error: 'failed', + } as never; + + await eventBus.emit('melt-op:finalized', { + mintUrl, + operationId: 'melt-op-failed', + operation: failed, }); - it('updates melt history entries to UNPAID on melt-op:rolled-back and emits an update', async () => { - const operation = makeRolledBackMeltOperation('melt-rolled-back', { - amount: Amount.from(325), - }); - - await mockRepo.addHistoryEntry({ - type: 'melt', - mintUrl: operation.mintUrl, - quoteId: operation.quoteId, - amount: operation.amount, - state: 'PENDING', - unit: 'sat', - createdAt: operation.createdAt, - } as Omit); + expect(historyUpdateEvents).toHaveLength(0); + }); - await eventBus.emit('melt-op:rolled-back', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - }); + it('emits mint operation state and keeps remote quote state as metadata', async () => { + const failed = makeFailedMintOperation('mint-op-1', 'quote-1'); - expect(historyEntries.size).toBe(1); - const entry = Array.from(historyEntries.values())[0] as MeltHistoryEntry; - expect(entry.quoteId).toBe(operation.quoteId); - expect(entry.state).toBe('UNPAID'); - expect(historyUpdateEvents.length).toBe(1); - expect(historyUpdateEvents[0]?.entry.type).toBe('melt'); - expect((historyUpdateEvents[0]?.entry as MeltHistoryEntry).quoteId).toBe(operation.quoteId); - expect((historyUpdateEvents[0]?.entry as MeltHistoryEntry).state).toBe('UNPAID'); + await eventBus.emit('mint-op:finalized', { + mintUrl, + operationId: failed.id, + operation: failed, }); - it('does not remove mint history entries that share a quoteId with a rolled back melt', async () => { - const operation = makeRolledBackMeltOperation('shared-quote-id', { - amount: Amount.from(325), - }); - - await mockRepo.addHistoryEntry({ - type: 'mint', - mintUrl: operation.mintUrl, - quoteId: operation.quoteId, - amount: Amount.from(500), - state: 'PAID', - unit: 'sat', - paymentRequest: 'lnbc500...', - createdAt: operation.createdAt - 1, - } as Omit); - - await mockRepo.addHistoryEntry({ - type: 'melt', - mintUrl: operation.mintUrl, - quoteId: operation.quoteId, - amount: operation.amount, - state: 'UNPAID', - unit: 'sat', - createdAt: operation.createdAt, - } as Omit); - - await eventBus.emit('melt-op:rolled-back', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation, - }); - - expect(historyEntries.size).toBe(2); - - const mintEntry = Array.from(historyEntries.values()).find( - (entry): entry is MintHistoryEntry => entry.type === 'mint', - ); - const meltEntry = Array.from(historyEntries.values()).find( - (entry): entry is MeltHistoryEntry => entry.type === 'melt', - ); - - expect(mintEntry).not.toBeUndefined(); - expect(mintEntry?.quoteId).toBe(operation.quoteId); - expect(mintEntry?.state).toBe('PAID'); - - expect(meltEntry).not.toBeUndefined(); - expect(meltEntry?.quoteId).toBe(operation.quoteId); - expect(meltEntry?.state).toBe('UNPAID'); - - expect(historyUpdateEvents.length).toBe(1); - expect(historyUpdateEvents[0]?.entry.type).toBe('melt'); + expect(historyUpdateEvents).toHaveLength(1); + expect(historyUpdateEvents[0]?.entry).toMatchObject({ + id: `mint:${failed.id}`, + source: 'operation', + type: 'mint', + quoteId: failed.quoteId, + state: 'failed', + remoteState: 'PAID', + error: 'expired', }); }); - describe('receive operations', () => { - it('does not create history entry from receive-op:prepared', async () => { - const operation = makePreparedReceiveOperation('receive-op-1'); - - await eventBus.emit('receive-op:prepared', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation: { ...operation, unit: 'usd' }, - }); + it('ignores receive prepared and emits only terminal receive history', async () => { + const prepared = makePreparedReceiveOperation('receive-op-1'); + const finalized = { ...prepared, state: 'finalized' } as FinalizedReceiveOperation; + const rolledBack = { + ...prepared, + id: 'receive-op-2', + state: 'rolled_back', + error: 'cancelled', + } as RolledBackReceiveOperation; - expect(historyEntries.size).toBe(0); - expect(historyUpdateEvents.length).toBe(0); + await eventBus.emit('receive-op:prepared', { + mintUrl, + operationId: prepared.id, + operation: prepared, + }); + await eventBus.emit('receive-op:finalized', { + mintUrl, + operationId: finalized.id, + operation: finalized, + }); + await eventBus.emit('receive-op:rolled-back', { + mintUrl, + operationId: rolledBack.id, + operation: rolledBack, }); - it('creates receive history from receive-op:finalized', async () => { - const operation = makeFinalizedReceiveOperation('receive-op-2'); - await eventBus.emit('receive-op:finalized', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation: { ...operation, unit: 'usd' }, - }); - - expect(historyEntries.size).toBe(1); - const entry = Array.from(historyEntries.values())[0] as ReceiveHistoryEntry; - expect(entry.state).toBe('finalized'); - expect(entry.unit).toBe('usd'); - expect(entry.operationId).toBe(operation.id); - expect(entry.token).toEqual({ - mint: operation.mintUrl, - proofs: operation.inputProofs, - unit: 'usd', - }); - expect(historyUpdateEvents.length).toBe(1); + expect(historyUpdateEvents).toHaveLength(2); + expect(historyUpdateEvents[0]?.entry).toMatchObject({ + id: `receive:${finalized.id}`, + source: 'operation', + type: 'receive', + state: 'finalized', + token: { + mint: mintUrl, + proofs: receiveProofs, + unit: 'sat', + }, + }); + expect(historyUpdateEvents[1]?.entry).toMatchObject({ + id: `receive:${rolledBack.id}`, + source: 'operation', + type: 'receive', + state: 'rolled_back', + error: 'cancelled', }); + }); - it('creates receive history entry from receive-op:rolled-back', async () => { - const operation = makeRolledBackReceiveOperation('receive-op-3'); + it('reads paginated history and operation ids through the projection repository', async () => { + const entry = { + id: 'send:send-op-1', + source: 'operation', + type: 'send', + operationId: 'send-op-1', + mintUrl, + amount: Amount.from(1), + unit: 'sat', + state: 'pending', + createdAt: 1, + updatedAt: 2, + } satisfies HistoryEntry; + repositoryEntries.set(entry.id, entry); - await eventBus.emit('receive-op:rolled-back', { - mintUrl: operation.mintUrl, - operationId: operation.id, - operation: { ...operation, unit: 'usd' }, - }); + await expect(service.getPaginatedHistory(0, 10)).resolves.toEqual([entry]); + await expect(service.getOperationIdFromHistoryEntry(entry.id)).resolves.toBe('send-op-1'); + }); - expect(historyEntries.size).toBe(1); - const entry = Array.from(historyEntries.values())[0] as ReceiveHistoryEntry; - expect(entry.state).toBe('rolledBack'); - expect(entry.unit).toBe('usd'); - expect(entry.operationId).toBe(operation.id); - expect(historyUpdateEvents.length).toBe(1); - }); + it('rejects operation-id lookup for legacy send entries without an operation id', async () => { + const legacy = { + id: 'legacy:1', + source: 'legacy', + legacyHistoryId: '1', + type: 'send', + mintUrl, + amount: Amount.from(1), + unit: 'sat', + state: 'pending', + createdAt: 1, + updatedAt: 1, + } satisfies LegacyHistoryEntry; + repositoryEntries.set(legacy.id, legacy); + + await expect(service.getOperationIdFromHistoryEntry(legacy.id)).rejects.toThrow( + 'not backed by an operation', + ); }); + + function makePreparedSendOperation(id: string): PreparedSendOperation { + return { + id, + state: 'prepared', + mintUrl, + amount: Amount.from(100), + method: 'default', + methodData: {}, + needsSwap: false, + fee: Amount.from(0), + inputAmount: Amount.from(100), + inputProofSecrets: ['secret-1'], + createdAt: 1_000, + updatedAt: 2_000, + }; + } + + function makePreparedMeltOperation(id: string, quoteId: string): PreparedMeltOperation { + return { + id, + state: 'prepared', + mintUrl, + method: 'bolt11', + methodData: { invoice: `lnbc-${quoteId}` }, + unit: 'sat', + amount: Amount.from(100), + needsSwap: false, + fee_reserve: Amount.from(10), + quoteId, + swap_fee: Amount.from(0), + inputAmount: Amount.from(110), + inputProofSecrets: ['secret-1'], + changeOutputData: { keep: [], send: [] }, + createdAt: 1_000, + updatedAt: 2_000, + }; + } + + function makeFailedMintOperation(id: string, quoteId: string): FailedMintOperation { + return { + id, + state: 'failed', + mintUrl, + method: 'bolt11', + methodData: {}, + amount: Amount.from(100), + unit: 'sat', + quoteId, + request: 'lnbc100', + expiry: null, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: 2_000, + outputData: { keep: [], send: [] }, + createdAt: 1_000, + updatedAt: 2_000, + error: 'expired', + } as FailedMintOperation; + } + + function makePreparedReceiveOperation(id: string): PreparedReceiveOperation { + return { + id, + state: 'prepared', + mintUrl, + unit: 'sat', + amount: Amount.from(42), + fee: Amount.from(1), + outputData: { keep: [], send: [] }, + inputProofs: receiveProofs, + createdAt: 1_000, + updatedAt: 2_000, + }; + } }); diff --git a/packages/core/test/unit/Manager.test.ts b/packages/core/test/unit/Manager.test.ts index 1ce666d9..55f54794 100644 --- a/packages/core/test/unit/Manager.test.ts +++ b/packages/core/test/unit/Manager.test.ts @@ -1,6 +1,9 @@ +import { Amount } from '@cashu/cashu-ts'; import { describe, it, beforeEach, expect, mock } from 'bun:test'; import { initializeCoco, type CocoConfig, Manager } from '../../Manager'; import { PaymentRequestsApi } from '../../api/PaymentRequestsApi'; +import type { CoreEvents } from '../../events/types'; +import type { PendingMintOperation } from '../../operations/mint'; import { MemoryRepositories } from '../../repositories/memory'; import { NullLogger } from '../../logging'; @@ -61,6 +64,50 @@ describe('initializeCoco', () => { await manager.disableMintOperationProcessor(); }); + it('persists mint quote observations before emitting projected history updates', async () => { + const manager = await initializeCoco({ + ...baseConfig, + watchers: { + mintOperationWatcher: { disabled: true }, + proofStateWatcher: { disabled: true }, + }, + processors: { + mintOperationProcessor: { disabled: true }, + }, + }); + const operation = makePendingMintOperation('mint-op-1', 'quote-1'); + await repositories.mintOperationRepository.create(operation); + + const observedRepositoryEntries: Array = []; + manager['eventBus'].on('history:updated', async ({ entry }) => { + observedRepositoryEntries.push( + await repositories.historyRepository.getHistoryEntryById(entry.id), + ); + }); + + const observedAt = 3_000; + await manager['eventBus'].emit('mint-op:quote-state-changed', { + mintUrl: operation.mintUrl, + operationId: operation.id, + operation: { + ...operation, + lastObservedRemoteState: 'PAID', + lastObservedRemoteStateAt: observedAt, + updatedAt: observedAt, + }, + quoteId: operation.quoteId, + state: 'PAID', + }); + + expect(observedRepositoryEntries).toHaveLength(1); + expect(observedRepositoryEntries[0]).toMatchObject({ + id: `mint:${operation.id}`, + type: 'mint', + operationId: operation.id, + remoteState: 'PAID', + }); + }); + it('should use NullLogger by default', async () => { const manager = await initializeCoco(baseConfig); @@ -122,6 +169,24 @@ describe('initializeCoco', () => { }); }); + function makePendingMintOperation(id: string, quoteId: string): PendingMintOperation { + return { + id, + state: 'pending', + mintUrl: 'https://mint.test', + method: 'bolt11', + methodData: {}, + amount: Amount.from(100), + unit: 'sat', + quoteId, + request: 'lnbc100', + expiry: null, + outputData: { keep: [], send: [] }, + createdAt: 1_000, + updatedAt: 2_000, + }; + } + describe('watchers configuration', () => { it('should disable mintOperationWatcher when explicitly disabled', async () => { const manager = await initializeCoco({ diff --git a/packages/core/test/unit/MemoryHistoryRepository.test.ts b/packages/core/test/unit/MemoryHistoryRepository.test.ts new file mode 100644 index 00000000..1ee7d92a --- /dev/null +++ b/packages/core/test/unit/MemoryHistoryRepository.test.ts @@ -0,0 +1,224 @@ +import { Amount } from '@cashu/cashu-ts'; +import { beforeEach, describe, expect, it } from 'bun:test'; +import { MemoryHistoryRepository } from '../../repositories/memory/MemoryHistoryRepository'; +import { MemoryMeltOperationRepository } from '../../repositories/memory/MemoryMeltOperationRepository'; +import { MemoryMintOperationRepository } from '../../repositories/memory/MemoryMintOperationRepository'; +import { MemorySendOperationRepository } from '../../repositories/memory/MemorySendOperationRepository'; +import type { PreparedMeltOperation } from '../../operations/melt'; +import type { PendingMintOperation } from '../../operations/mint'; +import type { PreparedSendOperation } from '../../operations/send/SendOperation'; + +describe('MemoryHistoryRepository', () => { + let sendOperationRepository: MemorySendOperationRepository; + let meltOperationRepository: MemoryMeltOperationRepository; + let mintOperationRepository: MemoryMintOperationRepository; + let historyRepository: MemoryHistoryRepository; + + beforeEach(() => { + sendOperationRepository = new MemorySendOperationRepository(); + meltOperationRepository = new MemoryMeltOperationRepository(); + mintOperationRepository = new MemoryMintOperationRepository(); + historyRepository = new MemoryHistoryRepository({ + sendOperationRepository, + meltOperationRepository, + mintOperationRepository, + }); + }); + + it('projects operation-backed entries with deterministic ids and operation states', async () => { + const send = makePreparedSendOperation('send-op-1', 2_000); + const melt = makePreparedMeltOperation('melt-op-1', 'quote-1', 3_000); + + await sendOperationRepository.create(send); + await meltOperationRepository.create(melt); + + const history = await historyRepository.getPaginatedHistoryEntries(10, 0); + + expect(history.map((entry) => entry.id)).toEqual(['melt:melt-op-1', 'send:send-op-1']); + expect(history[0]).toMatchObject({ + source: 'operation', + type: 'melt', + state: 'prepared', + operationId: 'melt-op-1', + }); + expect(history[1]).toMatchObject({ + source: 'operation', + type: 'send', + state: 'prepared', + operationId: 'send-op-1', + }); + }); + + it('keeps legacy rows visible when no operation-backed projection exists', async () => { + await historyRepository.addLegacyHistoryEntry({ + legacyHistoryId: 1, + type: 'send', + mintUrl: 'https://mint.test', + unit: 'sat', + amount: Amount.from(1), + createdAt: 1_000, + state: 'rolledBack', + }); + + const history = await historyRepository.getPaginatedHistoryEntries(10, 0); + + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + id: 'legacy:1', + source: 'legacy', + type: 'send', + state: 'rolledBack', + updatedAt: 1_000, + }); + }); + + it('preserves legacy null-state compatibility defaults', async () => { + await historyRepository.addLegacyHistoryEntry({ + legacyHistoryId: 1, + type: 'send', + mintUrl: 'https://mint.test', + unit: 'sat', + amount: Amount.from(1), + createdAt: 1_000, + }); + await historyRepository.addLegacyHistoryEntry({ + legacyHistoryId: 2, + type: 'receive', + mintUrl: 'https://mint.test', + unit: 'sat', + amount: Amount.from(2), + createdAt: 2_000, + }); + + const history = await historyRepository.getPaginatedHistoryEntries(10, 0); + + expect(history).toMatchObject([ + { + id: 'legacy:2', + type: 'receive', + state: 'finalized', + }, + { + id: 'legacy:1', + type: 'send', + state: 'pending', + }, + ]); + }); + + it('hides legacy rows with the same type and operationId as an operation projection', async () => { + const send = makePreparedSendOperation('send-op-1', 2_000); + await sendOperationRepository.create(send); + await historyRepository.addLegacyHistoryEntry({ + legacyHistoryId: 1, + type: 'send', + mintUrl: send.mintUrl, + unit: 'sat', + amount: send.amount, + createdAt: 1_000, + state: 'pending', + operationId: send.id, + }); + + const history = await historyRepository.getPaginatedHistoryEntries(10, 0); + + expect(history.map((entry) => entry.id)).toEqual(['send:send-op-1']); + }); + + it('hides legacy mint and melt rows with the same mint and quote as an operation projection', async () => { + const mint = makePendingMintOperation('mint-op-1', 'shared-quote', 4_000); + const melt = makePreparedMeltOperation('melt-op-1', 'shared-quote', 3_000); + + await mintOperationRepository.create(mint); + await meltOperationRepository.create(melt); + await historyRepository.addLegacyHistoryEntry({ + legacyHistoryId: 1, + type: 'mint', + mintUrl: mint.mintUrl, + unit: mint.unit, + amount: mint.amount, + createdAt: 1_000, + state: 'PAID', + quoteId: mint.quoteId, + paymentRequest: mint.request, + }); + await historyRepository.addLegacyHistoryEntry({ + legacyHistoryId: 2, + type: 'melt', + mintUrl: melt.mintUrl, + unit: melt.unit, + amount: melt.amount, + createdAt: 1_500, + state: 'UNPAID', + quoteId: melt.quoteId, + }); + + const history = await historyRepository.getPaginatedHistoryEntries(10, 0); + + expect(history.map((entry) => entry.id)).toEqual(['mint:mint-op-1', 'melt:melt-op-1']); + }); + + function makePreparedSendOperation(id: string, createdAt: number): PreparedSendOperation { + return { + id, + state: 'prepared', + mintUrl: 'https://mint.test', + amount: Amount.from(10), + method: 'default', + methodData: {}, + needsSwap: false, + fee: Amount.from(0), + inputAmount: Amount.from(10), + inputProofSecrets: ['secret-1'], + createdAt, + updatedAt: createdAt, + }; + } + + function makePreparedMeltOperation( + id: string, + quoteId: string, + createdAt: number, + ): PreparedMeltOperation { + return { + id, + state: 'prepared', + mintUrl: 'https://mint.test', + method: 'bolt11', + methodData: { invoice: `lnbc-${quoteId}` }, + unit: 'sat', + amount: Amount.from(20), + needsSwap: false, + fee_reserve: Amount.from(1), + quoteId, + swap_fee: Amount.from(0), + inputAmount: Amount.from(21), + inputProofSecrets: ['secret-1'], + changeOutputData: { keep: [], send: [] }, + createdAt, + updatedAt: createdAt, + }; + } + + function makePendingMintOperation( + id: string, + quoteId: string, + createdAt: number, + ): PendingMintOperation { + return { + id, + state: 'pending', + mintUrl: 'https://mint.test', + method: 'bolt11', + methodData: {}, + amount: Amount.from(30), + unit: 'sat', + quoteId, + request: 'lnbc30', + expiry: null, + outputData: { keep: [], send: [] }, + createdAt, + updatedAt: createdAt, + }; + } +}); diff --git a/packages/core/test/unit/ReceiveOperationService.test.ts b/packages/core/test/unit/ReceiveOperationService.test.ts index ce65afe8..bf3492d2 100644 --- a/packages/core/test/unit/ReceiveOperationService.test.ts +++ b/packages/core/test/unit/ReceiveOperationService.test.ts @@ -66,16 +66,6 @@ describe('ReceiveOperationService', () => { ), ); - const createDeferred = () => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; - }; - const createMockMintAdapter = (): MintAdapter => ({ checkProofStates: mock(() => Promise.resolve([])), @@ -232,41 +222,21 @@ describe('ReceiveOperationService', () => { expect((await receiveOpRepo.getById(prepared.id))?.state).toBe('rolled_back'); }); - it('rollback waits for receive rolled-back history persistence', async () => { + it('rollback emits terminal receive history projection', async () => { const proofs = [makeProof('p1')]; const token: Token = { mint: mintUrl, proofs } as Token; const initOp = await service.init(token); const prepared = await service.prepare(initOp); - const historyRepo = new MemoryHistoryRepository(); - const historyWriteStarted = createDeferred(); - const historyWriteRelease = createDeferred(); - const addHistoryEntry = historyRepo.addHistoryEntry.bind(historyRepo); - - historyRepo.addHistoryEntry = mock(async (entry) => { - historyWriteStarted.resolve(); - await historyWriteRelease.promise; - return addHistoryEntry(entry); + const historyRepo = new MemoryHistoryRepository({ + receiveOperationRepository: receiveOpRepo, }); new HistoryService(historyRepo, eventBus); - let rollbackResolved = false; - const rollbackPromise = service.rollback(prepared.id).then(() => { - rollbackResolved = true; - }); - - await historyWriteStarted.promise; - - expect(rollbackResolved).toBe(false); - expect((await receiveOpRepo.getById(prepared.id))?.state).toBe('rolled_back'); - expect(await historyRepo.getReceiveHistoryEntry(mintUrl, prepared.id)).toBeNull(); - - historyWriteRelease.resolve(); - await rollbackPromise; + await service.rollback(prepared.id); const historyEntry = await historyRepo.getReceiveHistoryEntry(mintUrl, prepared.id); - expect(rollbackResolved).toBe(true); - expect(historyEntry?.state).toBe('rolledBack'); + expect(historyEntry?.state).toBe('rolled_back'); }); it('init rejects untrusted mints', async () => { const proofs = [makeProof('p1')]; @@ -442,9 +412,11 @@ describe('ReceiveOperationService', () => { expect(stored?.error).toBe('Keyset unknown'); }); - it('updates receive history to rolledBack on generic mint protocol errors', async () => { + it('updates receive history to rolled_back on generic mint protocol errors', async () => { const proofs = [makeProof('p1')]; - const historyRepo = new MemoryHistoryRepository(); + const historyRepo = new MemoryHistoryRepository({ + receiveOperationRepository: receiveOpRepo, + }); new HistoryService(historyRepo, eventBus); const initOp = await service.init({ mint: mintUrl, proofs } as Token); @@ -459,7 +431,7 @@ describe('ReceiveOperationService', () => { await expect(service.execute(prepared)).rejects.toThrow('Keyset unknown'); const historyEntry = await historyRepo.getReceiveHistoryEntry(mintUrl, prepared.id); - expect(historyEntry?.state).toBe('rolledBack'); + expect(historyEntry?.state).toBe('rolled_back'); expect(historyEntry?.amount).toEqual(prepared.amount); }); diff --git a/packages/docs/.vitepress/config.ts b/packages/docs/.vitepress/config.ts index 440828e1..8d8a32cf 100644 --- a/packages/docs/.vitepress/config.ts +++ b/packages/docs/.vitepress/config.ts @@ -18,6 +18,7 @@ export default defineConfig({ items: [ { text: 'Start Here', link: '/starting/start-here' }, { text: 'Migrating from Alpha', link: '/starting/migrating-from-alpha' }, + { text: 'Migrating from v1', link: '/starting/migrating-from-v1' }, { text: 'Migrating to cashu-ts v4', link: '/starting/migrating-to-cashu-ts-v4' }, { text: 'Adding Mints', link: '/starting/adding-mints' }, { text: 'Subscriptions', link: '/starting/subscriptions' }, diff --git a/packages/docs/pages/send-operations.md b/packages/docs/pages/send-operations.md index 8f50ac54..1fbfcfba 100644 --- a/packages/docs/pages/send-operations.md +++ b/packages/docs/pages/send-operations.md @@ -195,13 +195,14 @@ coco.on('send:rolled-back', ({ mintUrl, operationId, operation }) => { ## History Integration -Send operations automatically create history entries. You can access them via the History API: +Send operations are projected into history from the operation repository. You can access them via +the History API: ```ts -const history = await coco.history.getHistory(); +const history = await coco.history.getPaginatedHistory(0, 25); for (const entry of history) { - if (entry.type === 'send') { + if (entry.type === 'send' && entry.source === 'operation') { console.log(`Send: ${entry.amount} sats, state: ${entry.state}`); console.log(`Operation ID: ${entry.operationId}`); diff --git a/packages/docs/starting/migrating-from-v1.md b/packages/docs/starting/migrating-from-v1.md new file mode 100644 index 00000000..e5e66bbf --- /dev/null +++ b/packages/docs/starting/migrating-from-v1.md @@ -0,0 +1,76 @@ +# Migrating from v1 + +This release changes history from a separately written history table into an +operation-first projection. + +Operations are now the canonical source of wallet activity. History reads are +derived from send, melt, mint, and receive operation repositories, with older +`coco_cashu_history` rows retained as read-only compatibility entries. + +## History entry identity + +Operation-backed history entries now use deterministic ids: + +- `send:` +- `melt:` +- `mint:` +- `receive:` + +Legacy rows from the old history table use `legacy:`. + +If your app stores history entry ids, treat ids from the previous history table +as legacy ids. New operation-backed entries should be linked by `operationId`. + +## Entry source + +Every history entry includes `source`: + +- `source: 'operation'` for entries projected from operation repositories +- `source: 'legacy'` for read-only fallback entries from old history rows + +Operation-backed entries always have `operationId`. Legacy entries may not. + +## State values + +Operation-backed history uses operation state names: + +- Send rollback is now `rolled_back`, not `rolledBack`. +- Receive rollback is now `rolled_back`, not `rolledBack`. +- Mint history uses mint operation states such as `pending`, `executing`, + `finalized`, and `failed`. +- Melt history uses melt operation states such as `prepared`, `pending`, + `finalized`, and `rolled_back`. + +Legacy entries preserve the old stored state strings, including protocol quote +states such as `UNPAID`, `PENDING`, `PAID`, and `ISSUED`. + +## Ordering and freshness + +History entries now expose both `createdAt` and `updatedAt`. + +Pagination is ordered by `createdAt DESC, id DESC`. Use `updatedAt` for +replacement, freshness, and realtime reconciliation, not for primary ordering. + +## Legacy fallback rows + +The old `coco_cashu_history` table or store remains readable. New operation +events no longer write to it. + +Legacy rows are hidden when an operation-backed entry represents the same +activity: + +- rows with `operationId` are hidden behind the same `type + operationId` +- mint and melt rows without `operationId` are hidden behind the same + `type + mintUrl + quoteId` + +Remaining legacy rows are best-effort display data and should not be treated as +operation lifecycle state. + +## Realtime updates + +`history:updated` still exists, but it now carries the operation-backed +projection for the changed operation. Consumers can update optimistically from +the payload, but repository reads remain authoritative. + +History ignores `receive-op:prepared`. Receive entries are projected only for +`finalized` and `rolled_back` states. diff --git a/packages/expo-sqlite/src/repositories/HistoryRepository.ts b/packages/expo-sqlite/src/repositories/HistoryRepository.ts index 6d7a31c3..43f2c591 100644 --- a/packages/expo-sqlite/src/repositories/HistoryRepository.ts +++ b/packages/expo-sqlite/src/repositories/HistoryRepository.ts @@ -1,428 +1,354 @@ import type { HistoryEntry, - MeltHistoryEntry, - MintHistoryEntry, - ReceiveHistoryEntry, - ReceiveHistoryState, - SendHistoryEntry, + HistoryRepository, + HistoryType, + LegacyHistoryRowInput, +} from '@cashu/coco-core'; +import type { Token } from '@cashu/cashu-ts'; +import { + deserializeAmount, + deserializeToken, + operationHistoryId, + parseHistoryEntryId, + projectLegacyHistoryRow, } from '@cashu/coco-core'; -import { deserializeAmount, deserializeToken, serializeAmount } from '@cashu/coco-core'; import { ExpoSqliteDb } from '../db.ts'; -type MintQuoteState = MintHistoryEntry['state']; -type MeltQuoteState = MeltHistoryEntry['state']; -type ReceiveToken = NonNullable; -type SendToken = NonNullable; -type SendHistoryState = SendHistoryEntry['state']; - -type Row = { - id: number; +type HistoryProjectionRow = { + source: 'operation' | 'legacy'; + id: string; + legacyHistoryId: string | null; + type: HistoryType; mintUrl: string; - type: 'mint' | 'melt' | 'send' | 'receive'; - unit: string; + unit: string | null; amount: string | number; createdAt: number; + updatedAt: number; + state: string; quoteId: string | null; - state: string | null; paymentRequest: string | null; tokenJson: string | null; + inputProofsJson: string | null; metadata: string | null; operationId: string | null; + remoteState: string | null; + error: string | null; }; -type NewHistoryEntry = - | Omit - | Omit - | Omit - | Omit; - -type UpdatableHistoryEntry = - | Omit - | Omit - | Omit - | Omit; +const projectionSelect = ` + SELECT * + FROM ( + SELECT + 'operation' AS source, + 'send:' || id AS id, + NULL AS legacyHistoryId, + 'send' AS type, + mintUrl, + 'sat' AS unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + NULL AS quoteId, + NULL AS paymentRequest, + tokenJson, + NULL AS inputProofsJson, + NULL AS metadata, + id AS operationId, + NULL AS remoteState, + error + FROM coco_cashu_send_operations + WHERE state != 'init' + + UNION ALL + + SELECT + 'operation' AS source, + 'melt:' || id AS id, + NULL AS legacyHistoryId, + 'melt' AS type, + mintUrl, + COALESCE(unit, 'sat') AS unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + quoteId, + NULL AS paymentRequest, + NULL AS tokenJson, + NULL AS inputProofsJson, + NULL AS metadata, + id AS operationId, + NULL AS remoteState, + error + FROM coco_cashu_melt_operations + WHERE state IN ('prepared', 'executing', 'pending', 'finalized', 'rolling_back', 'rolled_back') + + UNION ALL + + SELECT + 'operation' AS source, + 'mint:' || id AS id, + NULL AS legacyHistoryId, + 'mint' AS type, + mintUrl, + unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + quoteId, + request AS paymentRequest, + NULL AS tokenJson, + NULL AS inputProofsJson, + NULL AS metadata, + id AS operationId, + lastObservedRemoteState AS remoteState, + error + FROM coco_cashu_mint_operations + WHERE state != 'init' + + UNION ALL + + SELECT + 'operation' AS source, + 'receive:' || id AS id, + NULL AS legacyHistoryId, + 'receive' AS type, + mintUrl, + COALESCE(unit, 'sat') AS unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + NULL AS quoteId, + NULL AS paymentRequest, + NULL AS tokenJson, + inputProofsJson, + NULL AS metadata, + id AS operationId, + NULL AS remoteState, + error + FROM coco_cashu_receive_operations + WHERE state IN ('finalized', 'rolled_back') + + UNION ALL + + SELECT + 'legacy' AS source, + 'legacy:' || h.id AS id, + CAST(h.id AS TEXT) AS legacyHistoryId, + h.type, + h.mintUrl, + h.unit, + h.amount, + h.createdAt, + h.createdAt AS updatedAt, + COALESCE(h.state, '') AS state, + h.quoteId, + h.paymentRequest, + h.tokenJson, + NULL AS inputProofsJson, + h.metadata, + h.operationId, + NULL AS remoteState, + NULL AS error + FROM coco_cashu_history h + WHERE NOT ( + h.operationId IS NOT NULL AND EXISTS ( + SELECT 1 FROM ( + SELECT 'send' AS type, id AS operationId + FROM coco_cashu_send_operations + WHERE state != 'init' + UNION ALL + SELECT 'melt' AS type, id AS operationId + FROM coco_cashu_melt_operations + WHERE state IN ( + 'prepared', + 'executing', + 'pending', + 'finalized', + 'rolling_back', + 'rolled_back' + ) + UNION ALL + SELECT 'mint' AS type, id AS operationId + FROM coco_cashu_mint_operations + WHERE state != 'init' + UNION ALL + SELECT 'receive' AS type, id AS operationId + FROM coco_cashu_receive_operations + WHERE state IN ('finalized', 'rolled_back') + ) op + WHERE op.type = h.type AND op.operationId = h.operationId + ) + ) + AND NOT ( + h.operationId IS NULL + AND h.type IN ('mint', 'melt') + AND h.quoteId IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM ( + SELECT 'mint' AS type, mintUrl, quoteId + FROM coco_cashu_mint_operations + WHERE state != 'init' + UNION ALL + SELECT 'melt' AS type, mintUrl, quoteId + FROM coco_cashu_melt_operations + WHERE state IN ( + 'prepared', + 'executing', + 'pending', + 'finalized', + 'rolling_back', + 'rolled_back' + ) + ) opq + WHERE opq.type = h.type AND opq.mintUrl = h.mintUrl AND opq.quoteId = h.quoteId + ) + ) + ) +`; + +function parseToken(tokenJson: string | null): Token | undefined { + return tokenJson ? deserializeToken(JSON.parse(tokenJson)) : undefined; +} -function parseToken(tokenJson: string | null): TToken | undefined { - return tokenJson ? (deserializeToken(JSON.parse(tokenJson)) as TToken | undefined) : undefined; +function parseMetadata(metadata: string | null): Record | undefined { + return metadata ? JSON.parse(metadata) : undefined; } -export class ExpoHistoryRepository { +export class ExpoHistoryRepository implements HistoryRepository { private readonly db: ExpoSqliteDb; constructor(db: ExpoSqliteDb) { this.db = db; } - async getMintHistoryEntry(mintUrl: string, quoteId: string): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'mint' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, quoteId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'mint' ? entry : null; - } - - async getMeltHistoryEntry(mintUrl: string, quoteId: string): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'melt' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, quoteId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'melt' ? entry : null; - } - - async getSendHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'send' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, operationId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'send' ? entry : null; - } - - async getReceiveHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'receive' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, operationId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'receive' ? entry : null; - } - async getPaginatedHistoryEntries(limit: number, offset: number): Promise { - const rows = await this.db.all( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history + const rows = await this.db.all( + `${projectionSelect} ORDER BY createdAt DESC, id DESC LIMIT ? OFFSET ?`, [limit, offset], ); - return rows.map((r) => this.rowToEntry(r)); - } - - async getHistoryEntryById(id: string): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE id = ?`, - [id], - ); - if (!row) return null; - return this.rowToEntry(row); - } - - async addHistoryEntry(history: NewHistoryEntry): Promise { - const baseParams = [ - history.mintUrl, - history.type, - history.unit, - serializeAmount(history.amount), - history.createdAt, - ]; - - // Defaults for nullable columns - let quoteId: string | null = null; - let state: string | null = null; - let paymentRequest: string | null = null; - let tokenJson: string | null = null; - let metadata: string | null = history.metadata ? JSON.stringify(history.metadata) : null; - let operationId: string | null = null; - - switch (history.type) { - case 'mint': - quoteId = history.quoteId; - state = history.state; - paymentRequest = history.paymentRequest; - operationId = history.operationId ?? null; - break; - case 'melt': - quoteId = history.quoteId; - state = history.state; - operationId = history.operationId ?? null; - break; - case 'send': - tokenJson = history.token ? JSON.stringify(history.token as SendToken) : null; - operationId = history.operationId; - state = history.state; - break; - case 'receive': - tokenJson = history.token ? JSON.stringify(history.token as ReceiveToken) : null; - operationId = history.operationId ?? null; - state = history.state; - break; - } - - const result = await this.db.run( - `INSERT INTO coco_cashu_history (mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [...baseParams, quoteId, state, paymentRequest, tokenJson, metadata, operationId], - ); - const id = result.lastID; - return this.getById(id); - } - - async updateHistoryMintEntry( - mintUrl: string, - quoteId: string, - state: MintQuoteState, - ): Promise { - await this.db.run( - `UPDATE coco_cashu_history SET state = ? WHERE mintUrl = ? AND quoteId = ? AND type = 'mint'`, - [state, mintUrl, quoteId], - ); - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'mint' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, quoteId], - ); - if (!row) throw new Error('Updated mint history entry not found'); - return this.rowToEntry(row); - } - async updateHistoryMeltEntry( - mintUrl: string, - quoteId: string, - state: MeltQuoteState, - ): Promise { - await this.db.run( - `UPDATE coco_cashu_history SET state = ? WHERE mintUrl = ? AND quoteId = ? AND type = 'melt'`, - [state, mintUrl, quoteId], - ); - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'melt' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, quoteId], - ); - if (!row) throw new Error('Updated melt history entry not found'); - return this.rowToEntry(row); + return rows.map(rowToEntry); } - async updateHistoryEntry(history: UpdatableHistoryEntry): Promise { - let state: string | null = null; - let paymentRequest: string | null = null; - let tokenJson: string | null = null; - - if (history.type === 'mint') { - if (!history.quoteId) throw new Error('quoteId required for mint entry'); - state = history.state; - paymentRequest = history.paymentRequest; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, paymentRequest = ?, metadata = ?, operationId = ? - WHERE mintUrl = ? AND quoteId = ? AND type = 'mint'`, - [ - history.unit, - serializeAmount(history.amount), - state, - paymentRequest, - history.metadata ? JSON.stringify(history.metadata) : null, - history.operationId ?? null, - history.mintUrl, - history.quoteId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'mint' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, history.quoteId], - ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else if (history.type === 'melt') { - if (!history.quoteId) throw new Error('quoteId required for melt entry'); - state = history.state; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, metadata = ?, operationId = ? - WHERE mintUrl = ? AND quoteId = ? AND type = 'melt'`, - [ - history.unit, - serializeAmount(history.amount), - state, - history.metadata ? JSON.stringify(history.metadata) : null, - history.operationId ?? null, - history.mintUrl, - history.quoteId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'melt' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, history.quoteId], - ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else if (history.type === 'send') { - if (!history.operationId) throw new Error('operationId required for send entry'); - state = history.state; - tokenJson = history.token ? JSON.stringify(history.token) : null; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, tokenJson = ?, metadata = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'send'`, - [ - history.unit, - serializeAmount(history.amount), - state, - tokenJson, - history.metadata ? JSON.stringify(history.metadata) : null, - history.mintUrl, - history.operationId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'send' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, history.operationId], - ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else if (history.type === 'receive') { - if (!history.operationId) throw new Error('operationId required for receive entry'); - state = history.state; - tokenJson = history.token ? JSON.stringify(history.token as ReceiveToken) : null; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, tokenJson = ?, metadata = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'receive'`, - [ - history.unit, - serializeAmount(history.amount), - state, - tokenJson, - history.metadata ? JSON.stringify(history.metadata) : null, - history.mintUrl, - history.operationId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'receive' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, history.operationId], + async getHistoryEntryById(id: string): Promise { + const parsed = parseHistoryEntryId(id); + if (!parsed) return null; + + if (parsed.source === 'legacy') { + const row = await this.db.get( + `${projectionSelect} + WHERE source = 'legacy' AND legacyHistoryId = ? + LIMIT 1`, + [parsed.legacyHistoryId], ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else { - throw new Error(`Unsupported history entry type: ${String((history as HistoryEntry).type)}`); + return row ? rowToEntry(row) : null; } - } - async updateSendHistoryState( - mintUrl: string, - operationId: string, - state: SendHistoryState, - ): Promise { - await this.db.run( - `UPDATE coco_cashu_history SET state = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'send'`, - [state, mintUrl, operationId], + const row = await this.db.get( + `${projectionSelect} + WHERE source = 'operation' AND type = ? AND operationId = ? + LIMIT 1`, + [parsed.type, parsed.operationId], ); + return row ? rowToEntry(row) : null; } +} - async updateReceiveHistoryState( - mintUrl: string, - operationId: string, - state: ReceiveHistoryState, - ): Promise { - await this.db.run( - `UPDATE coco_cashu_history SET state = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'receive'`, - [state, mintUrl, operationId], - ); - } - - async deleteHistoryEntry(mintUrl: string, quoteId: string): Promise { - await this.db.run('DELETE FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ?', [ - mintUrl, - quoteId, - ]); - } - - private async getById(id: number): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE id = ? LIMIT 1`, - [id], - ); - if (!row) throw new Error('History entry not found'); - return this.rowToEntry(row); +function rowToEntry(row: HistoryProjectionRow): HistoryEntry { + if (row.source === 'legacy') { + return projectLegacyHistoryRow(rowToLegacyInput(row)); } - private rowToEntry(row: Row): HistoryEntry { - const base = { - id: String(row.id), - createdAt: row.createdAt, - mintUrl: row.mintUrl, - unit: row.unit, - metadata: row.metadata ? JSON.parse(row.metadata) : undefined, - } as const; - - if (row.type === 'mint') { + const base = { + id: operationHistoryId(row.type, row.operationId ?? ''), + source: 'operation' as const, + type: row.type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + mintUrl: row.mintUrl, + unit: row.unit ?? 'sat', + operationId: row.operationId ?? '', + amount: deserializeAmount(row.amount), + state: row.state, + ...(row.error ? { error: row.error } : {}), + }; + + switch (row.type) { + case 'mint': return { ...base, type: 'mint', - paymentRequest: row.paymentRequest ?? '', quoteId: row.quoteId ?? '', - operationId: row.operationId ?? undefined, - state: (row.state ?? 'UNPAID') as MintQuoteState, - amount: deserializeAmount(row.amount), - }; - } - if (row.type === 'melt') { + paymentRequest: row.paymentRequest ?? '', + state: row.state as HistoryEntry['state'], + ...(row.remoteState ? { remoteState: row.remoteState } : {}), + } as HistoryEntry; + case 'melt': return { ...base, type: 'melt', quoteId: row.quoteId ?? '', - operationId: row.operationId ?? undefined, - state: (row.state ?? 'UNPAID') as MeltQuoteState, - amount: deserializeAmount(row.amount), - }; - } - if (row.type === 'send') { + state: row.state as HistoryEntry['state'], + } as HistoryEntry; + case 'send': { + const token = parseToken(row.tokenJson); + return { ...base, type: 'send', - amount: deserializeAmount(row.amount), - operationId: row.operationId ?? '', - state: (row.state ?? 'pending') as SendHistoryState, - token: parseToken(row.tokenJson), - }; + unit: token?.unit ?? base.unit, + state: row.state as HistoryEntry['state'], + ...(token ? { token } : {}), + } as HistoryEntry; } - const token = parseToken(row.tokenJson); - return { - ...base, - type: 'receive', - amount: deserializeAmount(row.amount), - operationId: row.operationId ?? undefined, - state: (row.state ?? 'finalized') as ReceiveHistoryState, - token, - } satisfies HistoryEntry; + case 'receive': + return { + ...base, + type: 'receive', + state: row.state as HistoryEntry['state'], + ...(row.state === 'finalized' + ? { + token: { + mint: row.mintUrl, + proofs: parseTokenProofs(row.inputProofsJson), + unit: row.unit ?? 'sat', + }, + } + : {}), + } as HistoryEntry; } } + +function rowToLegacyInput(row: HistoryProjectionRow): LegacyHistoryRowInput { + return { + legacyHistoryId: row.legacyHistoryId ?? row.id.slice('legacy:'.length), + type: row.type, + createdAt: row.createdAt, + mintUrl: row.mintUrl, + unit: row.unit ?? 'sat', + amount: deserializeAmount(row.amount), + quoteId: row.quoteId, + state: row.state || null, + paymentRequest: row.paymentRequest, + token: parseToken(row.tokenJson) as LegacyHistoryRowInput['token'], + metadata: parseMetadata(row.metadata), + operationId: row.operationId, + }; +} + +function parseTokenProofs( + inputProofsJson: string | null, +): NonNullable['token']>['proofs'] { + const proofs = inputProofsJson ? JSON.parse(inputProofsJson) : []; + return proofs.map((proof: { amount: string | number }) => ({ + ...proof, + amount: deserializeAmount(proof.amount), + })); +} diff --git a/packages/expo-sqlite/src/schema.ts b/packages/expo-sqlite/src/schema.ts index 6828cbb2..67e8b95d 100644 --- a/packages/expo-sqlite/src/schema.ts +++ b/packages/expo-sqlite/src/schema.ts @@ -62,6 +62,31 @@ async function addSendOperationMethodColumns(db: ExpoSqliteDb): Promise { } } +async function backfillSendOperationTokensFromHistory(db: ExpoSqliteDb): Promise { + await db.run(` + UPDATE coco_cashu_send_operations + SET tokenJson = ( + SELECT h.tokenJson + FROM coco_cashu_history h + WHERE h.type = 'send' + AND h.operationId = coco_cashu_send_operations.id + AND h.mintUrl = coco_cashu_send_operations.mintUrl + AND h.tokenJson IS NOT NULL + ORDER BY h.createdAt DESC, h.id DESC + LIMIT 1 + ) + WHERE tokenJson IS NULL + AND EXISTS ( + SELECT 1 + FROM coco_cashu_history h + WHERE h.type = 'send' + AND h.operationId = coco_cashu_send_operations.id + AND h.mintUrl = coco_cashu_send_operations.mintUrl + AND h.tokenJson IS NOT NULL + ) + `); +} + async function migrateAmountColumnsToText(db: ExpoSqliteDb): Promise { if (await tableExists(db, 'coco_cashu_proofs')) { await db.exec(` @@ -948,6 +973,28 @@ const MIGRATIONS: readonly Migration[] = [ id: '024_amount_columns_text', run: migrateAmountColumnsToText, }, + { + id: '025_history_projection_indexes', + sql: ` + CREATE INDEX IF NOT EXISTS idx_coco_cashu_send_operations_createdAt + ON coco_cashu_send_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_melt_operations_createdAt + ON coco_cashu_melt_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_createdAt + ON coco_cashu_mint_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_receive_operations_createdAt + ON coco_cashu_receive_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_history_createdAt + ON coco_cashu_history(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_history_type_operation + ON coco_cashu_history(type, operationId) + WHERE operationId IS NOT NULL; + `, + }, + { + id: '026_backfill_send_operation_tokens', + run: backfillSendOperationTokensFromHistory, + }, ]; // Export for testing diff --git a/packages/expo-sqlite/src/test/schema.test.ts b/packages/expo-sqlite/src/test/schema.test.ts index c3e93714..12125309 100644 --- a/packages/expo-sqlite/src/test/schema.test.ts +++ b/packages/expo-sqlite/src/test/schema.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; // @ts-ignore bun:sqlite types are provided by the runtime in this workspace. import { Database } from 'bun:sqlite'; -import { ExpoSqliteDb, ensureSchemaUpTo } from '../index.ts'; +import { Amount } from '@cashu/coco-core'; +import { ExpoSqliteDb, ExpoHistoryRepository, ensureSchemaUpTo } from '../index.ts'; import type { ExpoSqliteDbOptions } from '../db.ts'; type RunResult = { changes: number; lastInsertRowId: number; lastInsertRowid: number }; @@ -85,6 +86,54 @@ async function getColumnNames(db: ExpoSqliteDb, tableName: string): Promise row.name); } +const LEGACY_SEND_TOKEN = { + mint: 'https://mint.test', + proofs: [{ id: 'keyset-1', amount: '100', secret: 'send-secret', C: 'C_send' }], + unit: 'sat', +}; + +async function seedSendOperationWithLegacyToken(db: ExpoSqliteDb): Promise { + await db.run( + `INSERT INTO coco_cashu_send_operations + (id, mintUrl, amount, state, createdAt, updatedAt, error, method, methodDataJson, + needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, tokenJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'send-op-token-backfill', + 'https://mint.test', + '100', + 'pending', + 2, + 3, + null, + 'default', + '{}', + 0, + '0', + '100', + '["secret-1"]', + null, + null, + ], + ); + + await db.run( + `INSERT INTO coco_cashu_history + (mintUrl, type, unit, amount, createdAt, state, tokenJson, operationId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'https://mint.test', + 'send', + 'sat', + '100', + 1_000, + 'pending', + JSON.stringify(LEGACY_SEND_TOKEN), + 'send-op-token-backfill', + ], + ); +} + describe('expo-sqlite schema migrations', () => { let database: BunExpoSqliteDatabaseShim; let db: ExpoSqliteDb; @@ -181,4 +230,33 @@ describe('expo-sqlite schema migrations', () => { expect.arrayContaining(['method', 'methodDataJson']), ); }); + + it('backfills send operation tokens from matching legacy history rows', async () => { + await ensureSchemaUpTo(db, '026_backfill_send_operation_tokens'); + await seedSendOperationWithLegacyToken(db); + + await ensureSchemaUpTo(db); + + const row = await db.get<{ tokenJson: string | null }>( + `SELECT tokenJson FROM coco_cashu_send_operations WHERE id = ?`, + ['send-op-token-backfill'], + ); + expect(row?.tokenJson).toBe(JSON.stringify(LEGACY_SEND_TOKEN)); + + const repository = new ExpoHistoryRepository(db); + const history = await repository.getPaginatedHistoryEntries(10, 0); + + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + id: 'send:send-op-token-backfill', + source: 'operation', + type: 'send', + operationId: 'send-op-token-backfill', + token: { + mint: 'https://mint.test', + unit: 'sat', + proofs: [{ amount: Amount.from(100), secret: 'send-secret' }], + }, + }); + }); }); diff --git a/packages/indexeddb/src/lib/schema.ts b/packages/indexeddb/src/lib/schema.ts index 0eca0edb..5f603d6a 100644 --- a/packages/indexeddb/src/lib/schema.ts +++ b/packages/indexeddb/src/lib/schema.ts @@ -553,4 +553,74 @@ export async function ensureSchema(db: IdbDb): Promise { row.amount = normalizeStoredAmount(row.amount); }); }); + + // Version 19: Add createdAt indexes used by operation-backed history projection pagination. + db.version(19).stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+id+state], state, mintUrl, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_melt_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl, createdAt', + coco_cashu_melt_operations: '&id, state, mintUrl, createdAt, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl, createdAt', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: '&id, state, mintUrl, createdAt, [mintUrl+quoteId]', + }); + + // Version 20: Preserve legacy send tokens when history is projected from operations. + db.version(20) + .stores({ + coco_cashu_mints: '&mintUrl, name, updatedAt, trusted', + coco_cashu_keysets: '&[mintUrl+id], mintUrl, id, updatedAt, unit', + coco_cashu_counters: '&[mintUrl+keysetId]', + coco_cashu_proofs: + '&[mintUrl+secret], [mintUrl+state], [mintUrl+id+state], state, mintUrl, id, usedByOperationId, createdByOperationId', + coco_cashu_mint_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_melt_quotes: '&[mintUrl+quote], state, mintUrl', + coco_cashu_history: + '++id, mintUrl, type, createdAt, [mintUrl+quoteId+type], [mintUrl+operationId]', + coco_cashu_keypairs: '&publicKey, createdAt, derivationIndex', + coco_cashu_send_operations: '&id, state, mintUrl, createdAt', + coco_cashu_melt_operations: '&id, state, mintUrl, createdAt, [mintUrl+quoteId]', + coco_cashu_receive_operations: '&id, state, mintUrl, createdAt', + coco_cashu_auth_sessions: '&mintUrl', + coco_cashu_mint_operations: '&id, state, mintUrl, createdAt, [mintUrl+quoteId]', + }) + .upgrade(async (tx) => { + const legacyRows = (await tx + .table('coco_cashu_history') + .where('type') + .equals('send') + .toArray()) as Array<{ + id: number; + mintUrl: string; + operationId?: string | null; + tokenJson?: string | null; + createdAt: number; + }>; + + legacyRows.sort((a, b) => b.createdAt - a.createdAt || b.id - a.id); + + const legacyTokens = new Map(); + for (const row of legacyRows) { + if (!row.operationId || !row.tokenJson) continue; + const key = `${row.mintUrl}\0${row.operationId}`; + if (!legacyTokens.has(key)) legacyTokens.set(key, row.tokenJson); + } + + await tx + .table('coco_cashu_send_operations') + .toCollection() + .modify((op: { id: string; mintUrl: string; tokenJson?: string | null }) => { + if (op.tokenJson != null) return; + const tokenJson = legacyTokens.get(`${op.mintUrl}\0${op.id}`); + if (tokenJson) op.tokenJson = tokenJson; + }); + }); } diff --git a/packages/indexeddb/src/repositories/HistoryRepository.test.ts b/packages/indexeddb/src/repositories/HistoryRepository.test.ts new file mode 100644 index 00000000..db1df637 --- /dev/null +++ b/packages/indexeddb/src/repositories/HistoryRepository.test.ts @@ -0,0 +1,249 @@ +/// + +// @ts-ignore bun:test types are provided by the test runner in this workspace. +import { describe, expect, it } from 'bun:test'; +import { IdbHistoryRepository } from './HistoryRepository.ts'; +import type { MeltOperationRow, ReceiveOperationRow, SendOperationRow } from '../lib/db.ts'; + +type StoreRows = Record; + +class FakeCollection { + constructor(private readonly rows: TRow[]) {} + + reverse(): FakeCollection { + return new FakeCollection([...this.rows].reverse()); + } + + filter(predicate: (row: TRow) => boolean): FakeCollection { + return new FakeCollection(this.rows.filter(predicate)); + } + + offset(count: number): FakeCollection { + return new FakeCollection(this.rows.slice(count)); + } + + limit(count: number): FakeCollection { + return new FakeCollection(this.rows.slice(0, count)); + } + + async toArray(): Promise { + return this.rows; + } +} + +class FakeTable { + constructor(private readonly rows: TRow[]) {} + + async get(id: string | number): Promise { + return this.rows.find((row) => (row as { id?: string }).id === id); + } + + orderBy(field: 'createdAt'): FakeCollection { + return new FakeCollection([...this.rows].sort((a, b) => a[field] - b[field])); + } + + where(index: string) { + return { + equals: (value: unknown) => ({ + first: async (): Promise => { + return this.rows.find((row) => rowMatchesIndex(row, index, value)); + }, + }), + }; + } +} + +function rowMatchesIndex(row: unknown, index: string, value: unknown): boolean { + if (index === '[mintUrl+quoteId]' && Array.isArray(value)) { + const [mintUrl, quoteId] = value; + const indexed = row as { mintUrl?: string; quoteId?: string | null }; + return indexed.mintUrl === mintUrl && indexed.quoteId === quoteId; + } + + return (row as Record)[index] === value; +} + +function makeDb(stores: StoreRows) { + const tx = { + table(name: string) { + return new FakeTable(stores[name] as { createdAt: number }[]); + }, + }; + + return { + async runTransaction(_mode: string, _stores: string[], fn: (txDb: typeof tx) => Promise) { + return fn(tx); + }, + }; +} + +describe('IdbHistoryRepository', () => { + it('filters operation rows before applying the per-store page window', async () => { + const receiveRows: ReceiveOperationRow[] = [ + makeReceiveRow('receive-prepared-2', 'prepared', 4), + makeReceiveRow('receive-prepared-1', 'prepared', 3), + makeReceiveRow('receive-finalized', 'finalized', 2), + ]; + const repository = new IdbHistoryRepository( + makeDb({ + coco_cashu_send_operations: [], + coco_cashu_melt_operations: [], + coco_cashu_mint_operations: [], + coco_cashu_receive_operations: receiveRows, + coco_cashu_history: [], + }) as never, + ); + + await expect(repository.getPaginatedHistoryEntries(1, 0)).resolves.toMatchObject([ + { + id: 'receive:receive-finalized', + type: 'receive', + state: 'finalized', + }, + ]); + }); + + it('over-scans legacy rows until it fills the visible page window', async () => { + const repository = new IdbHistoryRepository( + makeDb({ + coco_cashu_send_operations: [makeSendRow('send-dedup', 1)], + coco_cashu_melt_operations: [], + coco_cashu_mint_operations: [], + coco_cashu_receive_operations: [], + coco_cashu_history: [ + { + id: 1, + mintUrl: 'https://mint.test', + type: 'send', + unit: 'sat', + amount: '1', + createdAt: 5_000, + state: 'pending', + operationId: 'send-dedup', + }, + { + id: 2, + mintUrl: 'https://mint.test', + type: 'send', + unit: 'sat', + amount: '2', + createdAt: 4_000, + state: 'pending', + operationId: null, + }, + ], + }) as never, + ); + + await expect(repository.getPaginatedHistoryEntries(1, 0)).resolves.toMatchObject([ + { + id: 'legacy:2', + type: 'send', + state: 'pending', + }, + ]); + }); + + it('does not expose failed melt rows or use them to dedupe legacy rows', async () => { + const repository = new IdbHistoryRepository( + makeDb({ + coco_cashu_send_operations: [], + coco_cashu_melt_operations: [makeMeltRow('melt-failed', 'failed', 5)], + coco_cashu_mint_operations: [], + coco_cashu_receive_operations: [], + coco_cashu_history: [ + { + id: 1, + mintUrl: 'https://mint.test', + type: 'melt', + unit: 'sat', + amount: '1', + createdAt: 4_000, + state: 'UNPAID', + quoteId: 'quote-failed', + operationId: null, + }, + ], + }) as never, + ); + + await expect(repository.getHistoryEntryById('melt:melt-failed')).resolves.toBeNull(); + await expect(repository.getPaginatedHistoryEntries(10, 0)).resolves.toMatchObject([ + { + id: 'legacy:1', + type: 'melt', + state: 'UNPAID', + }, + ]); + }); +}); + +function makeReceiveRow( + id: string, + state: ReceiveOperationRow['state'], + createdAt: number, +): ReceiveOperationRow { + return { + id, + mintUrl: 'https://mint.test', + unit: 'sat', + amount: '1', + state, + createdAt, + updatedAt: createdAt, + error: null, + fee: '0', + inputProofsJson: '[]', + outputDataJson: null, + }; +} + +function makeSendRow(id: string, createdAt: number): SendOperationRow { + return { + id, + mintUrl: 'https://mint.test', + amount: '1', + state: 'prepared', + createdAt, + updatedAt: createdAt, + error: null, + method: 'default', + methodDataJson: '{}', + needsSwap: 0, + fee: '0', + inputAmount: '1', + inputProofSecretsJson: '[]', + outputDataJson: null, + tokenJson: null, + }; +} + +function makeMeltRow( + id: string, + state: MeltOperationRow['state'] | 'failed', + createdAt: number, +): MeltOperationRow { + return { + id, + mintUrl: 'https://mint.test', + state: state as MeltOperationRow['state'], + createdAt, + updatedAt: createdAt, + error: 'failed', + method: 'bolt11', + methodDataJson: '{}', + quoteId: 'quote-failed', + unit: 'sat', + amount: '1', + fee_reserve: '0', + swap_fee: '0', + needsSwap: 0, + inputAmount: '1', + inputProofSecretsJson: '[]', + changeOutputDataJson: '{"keep":[],"send":[]}', + swapOutputDataJson: null, + changeAmount: null, + effectiveFee: null, + finalizedDataJson: null, + }; +} diff --git a/packages/indexeddb/src/repositories/HistoryRepository.ts b/packages/indexeddb/src/repositories/HistoryRepository.ts index dbf99e04..aaab86c4 100644 --- a/packages/indexeddb/src/repositories/HistoryRepository.ts +++ b/packages/indexeddb/src/repositories/HistoryRepository.ts @@ -1,38 +1,81 @@ import type { Table } from 'dexie'; import type { HistoryEntry, - MintHistoryEntry, - MeltHistoryEntry, - ReceiveHistoryEntry, - ReceiveHistoryState, - SendHistoryEntry, - SendHistoryState, + HistoryRepository, + LegacyHistoryEntry, + LegacyHistoryRowInput, } from '@cashu/coco-core'; -import { deserializeAmount, deserializeToken, serializeAmount } from '@cashu/coco-core'; -import type { IdbDb } from '../lib/db.ts'; - -type MintQuoteState = MintHistoryEntry['state']; -type MeltQuoteState = MeltHistoryEntry['state']; -type SendToken = NonNullable; -type ReceiveToken = NonNullable; - -type NewHistoryEntry = - | Omit - | Omit - | Omit - | Omit; - -type UpdatableHistoryEntry = - | Omit - | Omit - | Omit - | Omit; - -function parseToken(tokenJson: string | null | undefined): TToken | undefined { - return tokenJson ? (deserializeToken(JSON.parse(tokenJson)) as TToken | undefined) : undefined; +import type { Token } from '@cashu/cashu-ts'; +import { + compareHistoryEntries, + deserializeAmount, + deserializeToken, + operationHistoryId, + parseHistoryEntryId, + projectLegacyHistoryRow, +} from '@cashu/coco-core'; +import type { + IdbDb, + MeltOperationRow, + MintOperationRow, + ReceiveOperationRow, + SendOperationRow, +} from '../lib/db.ts'; + +type LegacyHistoryRow = { + id: number; + mintUrl: string; + type: 'mint' | 'melt' | 'send' | 'receive'; + unit: string; + amount: string | number; + createdAt: number; + quoteId?: string | null; + state?: string | null; + paymentRequest?: string | null; + tokenJson?: string | null; + metadata?: Record | null; + operationId?: string | null; +}; + +type OperationRow = SendOperationRow | MeltOperationRow | MintOperationRow | ReceiveOperationRow; +type HistoryVisibleMeltState = Exclude; + +const stores = [ + 'coco_cashu_send_operations', + 'coco_cashu_melt_operations', + 'coco_cashu_mint_operations', + 'coco_cashu_receive_operations', + 'coco_cashu_history', +] as const; + +const historyVisibleMeltStates = new Set([ + 'prepared', + 'executing', + 'pending', + 'finalized', + 'rolling_back', + 'rolled_back', +]); + +function isHistoryVisibleMeltState(state: string): state is HistoryVisibleMeltState { + return historyVisibleMeltStates.has(state as HistoryVisibleMeltState); +} + +function parseToken(tokenJson: string | null | undefined): Token | undefined { + return tokenJson ? deserializeToken(JSON.parse(tokenJson)) : undefined; +} + +function parseReceiveProofs( + inputProofsJson: string | null | undefined, +): NonNullable['token']>['proofs'] { + const proofs = inputProofsJson ? JSON.parse(inputProofsJson) : []; + return proofs.map((proof: { amount: string | number }) => ({ + ...proof, + amount: deserializeAmount(proof.amount), + })); } -export class IdbHistoryRepository { +export class IdbHistoryRepository implements HistoryRepository { private readonly db: IdbDb; constructor(db: IdbDb) { @@ -40,266 +83,314 @@ export class IdbHistoryRepository { } async getPaginatedHistoryEntries(limit: number, offset: number): Promise { - const coll = this.db.table('coco_cashu_history') as Table; - const rows = await coll.orderBy('createdAt').reverse().offset(offset).limit(limit).toArray(); - return rows.map((r: any) => this.rowToEntry(r)); + const pageWindow = offset + limit; + if (pageWindow <= 0) return []; + + const entries = await this.db.runTransaction('r', [...stores], async (tx) => { + const [sendRows, meltRows, mintRows, receiveRows, legacyRows] = await Promise.all([ + this.readRecentOperationRows( + tx.table('coco_cashu_send_operations'), + pageWindow, + 'send', + ), + this.readRecentOperationRows( + tx.table('coco_cashu_melt_operations'), + pageWindow, + 'melt', + ), + this.readRecentOperationRows( + tx.table('coco_cashu_mint_operations'), + pageWindow, + 'mint', + ), + this.readRecentOperationRows( + tx.table('coco_cashu_receive_operations'), + pageWindow, + 'receive', + ), + this.readVisibleLegacyRows(tx, tx.table('coco_cashu_history'), pageWindow), + ]); + + const operationEntries = [ + ...sendRows.map((row) => this.sendRowToEntry(row)).filter(Boolean), + ...meltRows.map((row) => this.meltRowToEntry(row)).filter(Boolean), + ...mintRows.map((row) => this.mintRowToEntry(row)).filter(Boolean), + ...receiveRows.map((row) => this.receiveRowToEntry(row)).filter(Boolean), + ] as HistoryEntry[]; + + return [...operationEntries, ...legacyRows].sort(compareHistoryEntries); + }); + + return entries.slice(offset, offset + limit); } async getHistoryEntryById(id: string): Promise { - const row = await (this.db as any).table('coco_cashu_history').get(Number(id)); - if (!row) return null; - return this.rowToEntry(row); - } + const parsed = parseHistoryEntryId(id); + if (!parsed) return null; - async addHistoryEntry(history: NewHistoryEntry): Promise { - const row = this.entryToRow(history); - const id = (await (this.db as any).table('coco_cashu_history').add(row)) as number; - const stored = await (this.db as any).table('coco_cashu_history').get(id); - return this.rowToEntry(stored); - } + return this.db.runTransaction('r', [...stores], async (tx) => { + if (parsed.source === 'legacy') { + const row = (await tx.table('coco_cashu_history').get(Number(parsed.legacyHistoryId))) as + | LegacyHistoryRow + | undefined; + if (!row || (await this.legacyIsDeduped(tx, row))) return null; + return this.legacyRowToEntry(row); + } - async getMintHistoryEntry(mintUrl: string, quoteId: string): Promise { - const row = await (this.db as any) - .table('coco_cashu_history') - .where('[mintUrl+quoteId+type]') - .equals([mintUrl, quoteId, 'mint']) - .last(); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'mint' ? entry : null; + switch (parsed.type) { + case 'send': { + const row = (await tx.table('coco_cashu_send_operations').get(parsed.operationId)) as + | SendOperationRow + | undefined; + return row ? this.sendRowToEntry(row) : null; + } + case 'melt': { + const row = (await tx.table('coco_cashu_melt_operations').get(parsed.operationId)) as + | MeltOperationRow + | undefined; + return row ? this.meltRowToEntry(row) : null; + } + case 'mint': { + const row = (await tx.table('coco_cashu_mint_operations').get(parsed.operationId)) as + | MintOperationRow + | undefined; + return row ? this.mintRowToEntry(row) : null; + } + case 'receive': { + const row = (await tx.table('coco_cashu_receive_operations').get(parsed.operationId)) as + | ReceiveOperationRow + | undefined; + return row ? this.receiveRowToEntry(row) : null; + } + } + }); } - async getMeltHistoryEntry(mintUrl: string, quoteId: string): Promise { - const row = await (this.db as any) - .table('coco_cashu_history') - .where('[mintUrl+quoteId+type]') - .equals([mintUrl, quoteId, 'melt']) - .last(); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'melt' ? entry : null; + private async readRecentOperationRows( + table: Table, + limit: number, + type: LegacyHistoryRow['type'], + ): Promise { + return (await table + .orderBy('createdAt') + .reverse() + .filter((row) => this.operationIsHistoryEligible(type, row as OperationRow)) + .limit(limit) + .toArray()) as TRow[]; } - async getSendHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - const row = await (this.db as any) - .table('coco_cashu_history') - .where('[mintUrl+operationId]') - .equals([mintUrl, operationId]) - .last(); - if (!row || row.type !== 'send') return null; - const entry = this.rowToEntry(row); - return entry.type === 'send' ? entry : null; + private async readRecentRows( + table: Table, + offset: number, + limit: number, + ): Promise { + return (await table + .orderBy('createdAt') + .reverse() + .offset(offset) + .limit(limit) + .toArray()) as TRow[]; } - async getReceiveHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - const row = await (this.db as any) - .table('coco_cashu_history') - .where('[mintUrl+operationId]') - .equals([mintUrl, operationId]) - .last(); - if (!row || row.type !== 'receive') return null; - const entry = this.rowToEntry(row); - return entry.type === 'receive' ? entry : null; - } + private async readVisibleLegacyRows( + tx: { table(name: string): Table }, + table: Table, + limit: number, + ): Promise { + const entries: LegacyHistoryEntry[] = []; + const batchSize = Math.max(limit, 50); + let offset = 0; + + while (entries.length < limit) { + const rows = await this.readRecentRows(table, offset, batchSize); + if (rows.length === 0) break; + + for (const row of rows) { + if (await this.legacyIsDeduped(tx, row)) continue; + entries.push(this.legacyRowToEntry(row)); + if (entries.length >= limit) break; + } - async updateHistoryEntry(history: UpdatableHistoryEntry): Promise { - const coll = (this.db as any).table('coco_cashu_history'); - - if (history.type === 'mint') { - const rows = await coll - .where('[mintUrl+quoteId+type]') - .equals([history.mintUrl, history.quoteId, 'mint']) - .toArray(); - if (!rows.length) throw new Error('History entry not found'); - const row = rows[rows.length - 1]; - const updated = { - ...row, - unit: history.unit, - amount: serializeAmount(history.amount), - metadata: history.metadata ?? null, - operationId: history.operationId ?? null, - state: history.state, - paymentRequest: history.paymentRequest, - }; - await coll.update(row.id, updated); - const fresh = await coll.get(row.id); - return this.rowToEntry(fresh); - } else if (history.type === 'melt') { - const rows = await coll - .where('[mintUrl+quoteId+type]') - .equals([history.mintUrl, history.quoteId, 'melt']) - .toArray(); - if (!rows.length) throw new Error('History entry not found'); - const row = rows[rows.length - 1]; - const updated = { - ...row, - unit: history.unit, - amount: serializeAmount(history.amount), - metadata: history.metadata ?? null, - operationId: history.operationId ?? null, - state: history.state, - }; - await coll.update(row.id, updated); - const fresh = await coll.get(row.id); - return this.rowToEntry(fresh); - } else if (history.type === 'send') { - const rows = await coll - .where('[mintUrl+operationId]') - .equals([history.mintUrl, history.operationId]) - .toArray(); - if (!rows.length) throw new Error('History entry not found'); - const row = rows[rows.length - 1]; - const updated = { - ...row, - unit: history.unit, - amount: serializeAmount(history.amount), - metadata: history.metadata ?? null, - state: history.state, - tokenJson: history.token ? JSON.stringify(history.token) : row.tokenJson, - }; - await coll.update(row.id, updated); - const fresh = await coll.get(row.id); - return this.rowToEntry(fresh); - } else if (history.type === 'receive') { - const rows = await coll - .where('[mintUrl+operationId]') - .equals([history.mintUrl, history.operationId]) - .toArray(); - if (!rows.length) throw new Error('History entry not found'); - const row = rows[rows.length - 1]; - const updated = { - ...row, - unit: history.unit, - amount: serializeAmount(history.amount), - metadata: history.metadata ?? null, - state: history.state, - tokenJson: history.token ? JSON.stringify(history.token as ReceiveToken) : row.tokenJson, - }; - await coll.update(row.id, updated); - const fresh = await coll.get(row.id); - return this.rowToEntry(fresh); - } else { - throw new Error(`Unsupported history entry type: ${String((history as HistoryEntry).type)}`); + if (rows.length < batchSize) break; + offset += rows.length; } + + return entries; } - async updateSendHistoryState( - mintUrl: string, - operationId: string, - state: SendHistoryState, - ): Promise { - const coll = (this.db as any).table('coco_cashu_history'); - const rows = await coll.where('[mintUrl+operationId]').equals([mintUrl, operationId]).toArray(); - if (!rows.length) return; - const row = rows[rows.length - 1]; - await coll.update(row.id, { state }); + private sendRowToEntry(row: SendOperationRow): HistoryEntry | null { + if (row.state === 'init') return null; + const token = parseToken(row.tokenJson); + return { + id: operationHistoryId('send', row.id), + source: 'operation', + type: 'send', + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + mintUrl: row.mintUrl, + unit: token?.unit ?? 'sat', + operationId: row.id, + amount: deserializeAmount(row.amount), + state: row.state, + ...(row.error ? { error: row.error } : {}), + ...(token ? { token } : {}), + }; } - async updateReceiveHistoryState( - mintUrl: string, - operationId: string, - state: ReceiveHistoryState, - ): Promise { - const coll = (this.db as any).table('coco_cashu_history'); - const rows = await coll.where('[mintUrl+operationId]').equals([mintUrl, operationId]).toArray(); - if (!rows.length) return; - const row = rows[rows.length - 1]; - await coll.update(row.id, { state }); + private meltRowToEntry(row: MeltOperationRow): HistoryEntry | null { + if (!isHistoryVisibleMeltState(row.state)) return null; + return { + id: operationHistoryId('melt', row.id), + source: 'operation', + type: 'melt', + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + mintUrl: row.mintUrl, + unit: row.unit ?? 'sat', + operationId: row.id, + quoteId: row.quoteId ?? '', + amount: deserializeAmount(row.amount ?? 0), + state: row.state, + ...(row.error ? { error: row.error } : {}), + }; } - async deleteHistoryEntry(mintUrl: string, quoteId: string): Promise { - const coll = (this.db as any).table('coco_cashu_history'); - const rows = await coll - .where('[mintUrl+quoteId+type]') - .between([mintUrl, quoteId, ''], [mintUrl, quoteId, '']) - .toArray(); - const ids = rows.map((r: any) => r.id); - await coll.bulkDelete(ids); + private mintRowToEntry(row: MintOperationRow): HistoryEntry | null { + if (row.state === 'init') return null; + return { + id: operationHistoryId('mint', row.id), + source: 'operation', + type: 'mint', + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + mintUrl: row.mintUrl, + unit: row.unit ?? 'sat', + operationId: row.id, + quoteId: row.quoteId ?? '', + paymentRequest: row.request ?? '', + amount: deserializeAmount(row.amount ?? 0), + state: row.state, + ...(row.lastObservedRemoteState ? { remoteState: row.lastObservedRemoteState } : {}), + ...(row.error ? { error: row.error } : {}), + }; } - private entryToRow(history: NewHistoryEntry): any { - const base = { - mintUrl: history.mintUrl, - type: history.type, - unit: history.unit, - amount: serializeAmount(history.amount), - createdAt: history.createdAt, - metadata: history.metadata ?? null, - } as any; - if (history.type === 'mint') { - base.quoteId = history.quoteId; - base.operationId = history.operationId ?? null; - base.state = history.state as MintQuoteState; - base.paymentRequest = history.paymentRequest; - } else if (history.type === 'melt') { - base.quoteId = history.quoteId; - base.operationId = history.operationId ?? null; - base.state = history.state as MeltQuoteState; - } else if (history.type === 'send') { - base.tokenJson = history.token ? JSON.stringify(history.token as SendToken) : null; - base.operationId = history.operationId; - base.state = history.state; - } else if (history.type === 'receive') { - base.tokenJson = history.token ? JSON.stringify(history.token as ReceiveToken) : null; - base.operationId = history.operationId ?? null; - base.state = history.state; - } - return base; + private receiveRowToEntry(row: ReceiveOperationRow): HistoryEntry | null { + if (row.state !== 'finalized' && row.state !== 'rolled_back') return null; + return { + id: operationHistoryId('receive', row.id), + source: 'operation', + type: 'receive', + createdAt: row.createdAt * 1000, + updatedAt: row.updatedAt * 1000, + mintUrl: row.mintUrl, + unit: row.unit ?? 'sat', + operationId: row.id, + amount: deserializeAmount(row.amount), + state: row.state, + ...(row.error ? { error: row.error } : {}), + ...(row.state === 'finalized' + ? { + token: { + mint: row.mintUrl, + proofs: parseReceiveProofs(row.inputProofsJson), + unit: row.unit ?? 'sat', + }, + } + : {}), + }; + } + + private legacyRowToEntry(row: LegacyHistoryRow): LegacyHistoryEntry { + return projectLegacyHistoryRow(this.legacyRowToInput(row)); } - private rowToEntry(row: any): HistoryEntry { - const base = { - id: String(row.id), + private legacyRowToInput(row: LegacyHistoryRow): LegacyHistoryRowInput { + return { + legacyHistoryId: row.id, + type: row.type, createdAt: row.createdAt, mintUrl: row.mintUrl, unit: row.unit, + amount: deserializeAmount(row.amount), + quoteId: row.quoteId, + state: row.state, + paymentRequest: row.paymentRequest, + token: parseToken(row.tokenJson), metadata: row.metadata ?? undefined, - } as const; - if (row.type === 'mint') { - return { - ...base, - type: 'mint', - paymentRequest: row.paymentRequest ?? '', - quoteId: row.quoteId ?? '', - operationId: row.operationId ?? undefined, - state: (row.state ?? 'UNPAID') as MintQuoteState, - amount: deserializeAmount(row.amount), - }; + operationId: row.operationId, + }; + } + + private async legacyIsDeduped( + tx: { table(name: string): Table }, + row: LegacyHistoryRow, + ): Promise { + if (row.operationId) { + const operation = await this.getOperationRow(tx, row.type, row.operationId); + if (operation && this.operationIsHistoryEligible(row.type, operation)) return true; } - if (row.type === 'melt') { - return { - ...base, - type: 'melt', - quoteId: row.quoteId ?? '', - operationId: row.operationId ?? undefined, - state: (row.state ?? 'UNPAID') as MeltQuoteState, - amount: deserializeAmount(row.amount), - }; + + if ( + (row.type === 'mint' || row.type === 'melt') && + row.quoteId && + (await this.hasOperationForQuote(tx, row.type, row.mintUrl, row.quoteId)) + ) { + return true; } - if (row.type === 'send') { - return { - ...base, - type: 'send', - amount: deserializeAmount(row.amount), - operationId: row.operationId ?? '', - state: (row.state ?? 'pending') as SendHistoryState, - token: parseToken(row.tokenJson), - }; + + return false; + } + + private async getOperationRow( + tx: { table(name: string): Table }, + type: LegacyHistoryRow['type'], + operationId: string, + ): Promise { + switch (type) { + case 'send': + return (await tx.table('coco_cashu_send_operations').get(operationId)) as + | SendOperationRow + | undefined; + case 'melt': + return (await tx.table('coco_cashu_melt_operations').get(operationId)) as + | MeltOperationRow + | undefined; + case 'mint': + return (await tx.table('coco_cashu_mint_operations').get(operationId)) as + | MintOperationRow + | undefined; + case 'receive': + return (await tx.table('coco_cashu_receive_operations').get(operationId)) as + | ReceiveOperationRow + | undefined; } - const token = parseToken(row.tokenJson); - return { - ...base, - type: 'receive', - amount: deserializeAmount(row.amount), - operationId: row.operationId ?? undefined, - state: (row.state ?? 'finalized') as ReceiveHistoryState, - token, - } satisfies HistoryEntry; + } + + private operationIsHistoryEligible(type: LegacyHistoryRow['type'], row: OperationRow): boolean { + switch (type) { + case 'send': + case 'mint': + return row.state !== 'init'; + case 'melt': + return isHistoryVisibleMeltState(row.state); + case 'receive': + return row.state === 'finalized' || row.state === 'rolled_back'; + } + } + + private async hasOperationForQuote( + tx: { table(name: string): Table }, + type: 'mint' | 'melt', + mintUrl: string, + quoteId: string, + ): Promise { + const store = type === 'mint' ? 'coco_cashu_mint_operations' : 'coco_cashu_melt_operations'; + const row = (await tx + .table(store) + .where('[mintUrl+quoteId]') + .equals([mintUrl, quoteId]) + .first()) as OperationRow | undefined; + return row ? this.operationIsHistoryEligible(type, row) : false; } } diff --git a/packages/sqlite-bun/src/repositories/HistoryRepository.ts b/packages/sqlite-bun/src/repositories/HistoryRepository.ts index 65438c8d..1ecdda1d 100644 --- a/packages/sqlite-bun/src/repositories/HistoryRepository.ts +++ b/packages/sqlite-bun/src/repositories/HistoryRepository.ts @@ -1,38 +1,222 @@ import type { HistoryEntry, HistoryRepository, - MintHistoryEntry, - MeltHistoryEntry, - ReceiveHistoryEntry, - ReceiveHistoryState, - SendHistoryEntry, - SendHistoryState, + HistoryType, + LegacyHistoryRowInput, +} from '@cashu/coco-core'; +import type { Token } from '@cashu/cashu-ts'; +import { + deserializeAmount, + deserializeToken, + operationHistoryId, + parseHistoryEntryId, + projectLegacyHistoryRow, } from '@cashu/coco-core'; -import { deserializeAmount, deserializeToken, serializeAmount } from '@cashu/coco-core'; import { SqliteDb } from '../db.ts'; -type MintQuoteState = MintHistoryEntry['state']; -type MeltQuoteState = MeltHistoryEntry['state']; -type ReceiveToken = NonNullable; -type SendToken = NonNullable; - -type Row = { - id: number; +type HistoryProjectionRow = { + source: 'operation' | 'legacy'; + id: string; + legacyHistoryId: string | null; + type: HistoryType; mintUrl: string; - type: 'mint' | 'melt' | 'send' | 'receive'; - unit: string; + unit: string | null; amount: string | number; createdAt: number; + updatedAt: number; + state: string; quoteId: string | null; - state: string | null; paymentRequest: string | null; tokenJson: string | null; + inputProofsJson: string | null; metadata: string | null; operationId: string | null; + remoteState: string | null; + error: string | null; }; -function parseToken(tokenJson: string | null): TToken | undefined { - return tokenJson ? (deserializeToken(JSON.parse(tokenJson)) as TToken | undefined) : undefined; +const projectionSelect = ` + SELECT * + FROM ( + SELECT + 'operation' AS source, + 'send:' || id AS id, + NULL AS legacyHistoryId, + 'send' AS type, + mintUrl, + 'sat' AS unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + NULL AS quoteId, + NULL AS paymentRequest, + tokenJson, + NULL AS inputProofsJson, + NULL AS metadata, + id AS operationId, + NULL AS remoteState, + error + FROM coco_cashu_send_operations + WHERE state != 'init' + + UNION ALL + + SELECT + 'operation' AS source, + 'melt:' || id AS id, + NULL AS legacyHistoryId, + 'melt' AS type, + mintUrl, + COALESCE(unit, 'sat') AS unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + quoteId, + NULL AS paymentRequest, + NULL AS tokenJson, + NULL AS inputProofsJson, + NULL AS metadata, + id AS operationId, + NULL AS remoteState, + error + FROM coco_cashu_melt_operations + WHERE state IN ('prepared', 'executing', 'pending', 'finalized', 'rolling_back', 'rolled_back') + + UNION ALL + + SELECT + 'operation' AS source, + 'mint:' || id AS id, + NULL AS legacyHistoryId, + 'mint' AS type, + mintUrl, + unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + quoteId, + request AS paymentRequest, + NULL AS tokenJson, + NULL AS inputProofsJson, + NULL AS metadata, + id AS operationId, + lastObservedRemoteState AS remoteState, + error + FROM coco_cashu_mint_operations + WHERE state != 'init' + + UNION ALL + + SELECT + 'operation' AS source, + 'receive:' || id AS id, + NULL AS legacyHistoryId, + 'receive' AS type, + mintUrl, + COALESCE(unit, 'sat') AS unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + NULL AS quoteId, + NULL AS paymentRequest, + NULL AS tokenJson, + inputProofsJson, + NULL AS metadata, + id AS operationId, + NULL AS remoteState, + error + FROM coco_cashu_receive_operations + WHERE state IN ('finalized', 'rolled_back') + + UNION ALL + + SELECT + 'legacy' AS source, + 'legacy:' || h.id AS id, + CAST(h.id AS TEXT) AS legacyHistoryId, + h.type, + h.mintUrl, + h.unit, + h.amount, + h.createdAt, + h.createdAt AS updatedAt, + COALESCE(h.state, '') AS state, + h.quoteId, + h.paymentRequest, + h.tokenJson, + NULL AS inputProofsJson, + h.metadata, + h.operationId, + NULL AS remoteState, + NULL AS error + FROM coco_cashu_history h + WHERE NOT ( + h.operationId IS NOT NULL AND EXISTS ( + SELECT 1 FROM ( + SELECT 'send' AS type, id AS operationId + FROM coco_cashu_send_operations + WHERE state != 'init' + UNION ALL + SELECT 'melt' AS type, id AS operationId + FROM coco_cashu_melt_operations + WHERE state IN ( + 'prepared', + 'executing', + 'pending', + 'finalized', + 'rolling_back', + 'rolled_back' + ) + UNION ALL + SELECT 'mint' AS type, id AS operationId + FROM coco_cashu_mint_operations + WHERE state != 'init' + UNION ALL + SELECT 'receive' AS type, id AS operationId + FROM coco_cashu_receive_operations + WHERE state IN ('finalized', 'rolled_back') + ) op + WHERE op.type = h.type AND op.operationId = h.operationId + ) + ) + AND NOT ( + h.operationId IS NULL + AND h.type IN ('mint', 'melt') + AND h.quoteId IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM ( + SELECT 'mint' AS type, mintUrl, quoteId + FROM coco_cashu_mint_operations + WHERE state != 'init' + UNION ALL + SELECT 'melt' AS type, mintUrl, quoteId + FROM coco_cashu_melt_operations + WHERE state IN ( + 'prepared', + 'executing', + 'pending', + 'finalized', + 'rolling_back', + 'rolled_back' + ) + ) opq + WHERE opq.type = h.type AND opq.mintUrl = h.mintUrl AND opq.quoteId = h.quoteId + ) + ) + ) +`; + +function parseToken(tokenJson: string | null): Token | undefined { + return tokenJson ? deserializeToken(JSON.parse(tokenJson)) : undefined; +} + +function parseMetadata(metadata: string | null): Record | undefined { + return metadata ? JSON.parse(metadata) : undefined; } export class SqliteHistoryRepository implements HistoryRepository { @@ -43,344 +227,128 @@ export class SqliteHistoryRepository implements HistoryRepository { } async getPaginatedHistoryEntries(limit: number, offset: number): Promise { - const rows = await this.db.all( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history + const rows = await this.db.all( + `${projectionSelect} ORDER BY createdAt DESC, id DESC LIMIT ? OFFSET ?`, [limit, offset], ); - return rows.map((r) => this.rowToEntry(r)); - } - - async getHistoryEntryById(id: string): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE id = ?`, - [id], - ); - if (!row) return null; - return this.rowToEntry(row); - } - - async addHistoryEntry(history: Omit): Promise { - const baseParams = [ - history.mintUrl, - history.type, - history.unit, - serializeAmount(history.amount), - history.createdAt, - ]; - - let quoteId: string | null = null; - let state: string | null = null; - let paymentRequest: string | null = null; - let tokenJson: string | null = null; - let metadata: string | null = history.metadata ? JSON.stringify(history.metadata) : null; - let operationId: string | null = null; - - switch (history.type) { - case 'mint': { - const h = history as Omit; - quoteId = h.quoteId; - state = h.state; - paymentRequest = h.paymentRequest; - operationId = h.operationId ?? null; - break; - } - case 'melt': { - const h = history as Omit; - quoteId = h.quoteId; - state = h.state; - operationId = h.operationId ?? null; - break; - } - case 'send': { - const h = history as Omit; - tokenJson = h.token ? JSON.stringify(h.token as SendToken) : null; - operationId = h.operationId; - state = h.state; - break; - } - case 'receive': { - const h = history as Omit; - tokenJson = h.token ? JSON.stringify(h.token as ReceiveToken) : null; - operationId = h.operationId ?? null; - state = h.state; - break; - } - } - - const result = await this.db.run( - `INSERT INTO coco_cashu_history (mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [...baseParams, quoteId, state, paymentRequest, tokenJson, metadata, operationId], - ); - const id = result.lastID; - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE id = ?`, - [id], - ); - if (!row) throw new Error('History insert failed to return row'); - return this.rowToEntry(row); - } - - async getMintHistoryEntry(mintUrl: string, quoteId: string): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'mint' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, quoteId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'mint' ? entry : null; - } - - async getMeltHistoryEntry(mintUrl: string, quoteId: string): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'melt' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, quoteId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'melt' ? entry : null; - } - - async getSendHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'send' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, operationId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'send' ? entry : null; - } - async getReceiveHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'receive' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, operationId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'receive' ? entry : null; + return rows.map(rowToEntry); } - async updateHistoryEntry(history: Omit): Promise { - let state: string | null = null; - let paymentRequest: string | null = null; - let tokenJson: string | null = null; - - if (history.type === 'mint') { - const h = history as Omit; - if (!h.quoteId) throw new Error('quoteId required for mint entry'); - state = h.state; - paymentRequest = h.paymentRequest; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, paymentRequest = ?, metadata = ?, operationId = ? - WHERE mintUrl = ? AND quoteId = ? AND type = 'mint'`, - [ - history.unit, - serializeAmount(history.amount), - state, - paymentRequest, - history.metadata ? JSON.stringify(history.metadata) : null, - h.operationId ?? null, - history.mintUrl, - h.quoteId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'mint' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, h.quoteId], - ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else if (history.type === 'melt') { - const h = history as Omit; - if (!h.quoteId) throw new Error('quoteId required for melt entry'); - state = h.state; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, metadata = ?, operationId = ? - WHERE mintUrl = ? AND quoteId = ? AND type = 'melt'`, - [ - history.unit, - serializeAmount(history.amount), - state, - history.metadata ? JSON.stringify(history.metadata) : null, - h.operationId ?? null, - history.mintUrl, - h.quoteId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'melt' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, h.quoteId], - ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else if (history.type === 'send') { - const h = history as Omit; - if (!h.operationId) throw new Error('operationId required for send entry'); - state = h.state; - tokenJson = h.token ? JSON.stringify(h.token as SendToken) : null; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, tokenJson = ?, metadata = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'send'`, - [ - history.unit, - serializeAmount(history.amount), - state, - tokenJson, - history.metadata ? JSON.stringify(history.metadata) : null, - history.mintUrl, - h.operationId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'send' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, h.operationId], - ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else if (history.type === 'receive') { - const h = history as Omit; - if (!h.operationId) throw new Error('operationId required for receive entry'); - state = h.state; - tokenJson = h.token ? JSON.stringify(h.token as ReceiveToken) : null; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, tokenJson = ?, metadata = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'receive'`, - [ - history.unit, - serializeAmount(history.amount), - state, - tokenJson, - history.metadata ? JSON.stringify(history.metadata) : null, - history.mintUrl, - h.operationId, - ], - ); + async getHistoryEntryById(id: string): Promise { + const parsed = parseHistoryEntryId(id); + if (!parsed) return null; - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'receive' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, h.operationId], + if (parsed.source === 'legacy') { + const row = await this.db.get( + `${projectionSelect} + WHERE source = 'legacy' AND legacyHistoryId = ? + LIMIT 1`, + [parsed.legacyHistoryId], ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else { - throw new Error(`Unsupported history entry type: ${String((history as HistoryEntry).type)}`); + return row ? rowToEntry(row) : null; } - } - - async updateSendHistoryState( - mintUrl: string, - operationId: string, - state: SendHistoryState, - ): Promise { - await this.db.run( - `UPDATE coco_cashu_history SET state = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'send'`, - [state, mintUrl, operationId], - ); - } - async updateReceiveHistoryState( - mintUrl: string, - operationId: string, - state: ReceiveHistoryState, - ): Promise { - await this.db.run( - `UPDATE coco_cashu_history SET state = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'receive'`, - [state, mintUrl, operationId], + const row = await this.db.get( + `${projectionSelect} + WHERE source = 'operation' AND type = ? AND operationId = ? + LIMIT 1`, + [parsed.type, parsed.operationId], ); + return row ? rowToEntry(row) : null; } +} - async deleteHistoryEntry(mintUrl: string, quoteId: string): Promise { - await this.db.run('DELETE FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ?', [ - mintUrl, - quoteId, - ]); +function rowToEntry(row: HistoryProjectionRow): HistoryEntry { + if (row.source === 'legacy') { + return projectLegacyHistoryRow(rowToLegacyInput(row)); } - private rowToEntry(row: Row): HistoryEntry { - const base = { - id: String(row.id), - createdAt: row.createdAt, - mintUrl: row.mintUrl, - unit: row.unit, - metadata: row.metadata ? JSON.parse(row.metadata) : undefined, - } as const; + const base = { + id: operationHistoryId(row.type, row.operationId ?? ''), + source: 'operation' as const, + type: row.type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + mintUrl: row.mintUrl, + unit: row.unit ?? 'sat', + operationId: row.operationId ?? '', + amount: deserializeAmount(row.amount), + state: row.state, + ...(row.error ? { error: row.error } : {}), + }; - if (row.type === 'mint') { + switch (row.type) { + case 'mint': return { ...base, type: 'mint', - paymentRequest: row.paymentRequest ?? '', quoteId: row.quoteId ?? '', - operationId: row.operationId ?? undefined, - state: (row.state ?? 'UNPAID') as MintQuoteState, - amount: deserializeAmount(row.amount), - }; - } - if (row.type === 'melt') { + paymentRequest: row.paymentRequest ?? '', + state: row.state as HistoryEntry['state'], + ...(row.remoteState ? { remoteState: row.remoteState } : {}), + } as HistoryEntry; + case 'melt': return { ...base, type: 'melt', quoteId: row.quoteId ?? '', - operationId: row.operationId ?? undefined, - state: (row.state ?? 'UNPAID') as MeltQuoteState, - amount: deserializeAmount(row.amount), - }; - } - if (row.type === 'send') { + state: row.state as HistoryEntry['state'], + } as HistoryEntry; + case 'send': { + const token = parseToken(row.tokenJson); + return { ...base, type: 'send', - amount: deserializeAmount(row.amount), - operationId: row.operationId ?? '', - state: (row.state ?? 'pending') as SendHistoryState, - token: parseToken(row.tokenJson), - }; + unit: token?.unit ?? base.unit, + state: row.state as HistoryEntry['state'], + ...(token ? { token } : {}), + } as HistoryEntry; } - const token = parseToken(row.tokenJson); - return { - ...base, - type: 'receive', - amount: deserializeAmount(row.amount), - operationId: row.operationId ?? undefined, - state: (row.state ?? 'finalized') as ReceiveHistoryState, - token, - } satisfies HistoryEntry; + case 'receive': + return { + ...base, + type: 'receive', + state: row.state as HistoryEntry['state'], + ...(row.state === 'finalized' + ? { + token: { + mint: row.mintUrl, + proofs: parseTokenProofs(row.inputProofsJson), + unit: row.unit ?? 'sat', + }, + } + : {}), + } as HistoryEntry; } } + +function rowToLegacyInput(row: HistoryProjectionRow): LegacyHistoryRowInput { + return { + legacyHistoryId: row.legacyHistoryId ?? row.id.slice('legacy:'.length), + type: row.type, + createdAt: row.createdAt, + mintUrl: row.mintUrl, + unit: row.unit ?? 'sat', + amount: deserializeAmount(row.amount), + quoteId: row.quoteId, + state: row.state || null, + paymentRequest: row.paymentRequest, + token: parseToken(row.tokenJson) as LegacyHistoryRowInput['token'], + metadata: parseMetadata(row.metadata), + operationId: row.operationId, + }; +} + +function parseTokenProofs( + inputProofsJson: string | null, +): NonNullable['token']>['proofs'] { + const proofs = inputProofsJson ? JSON.parse(inputProofsJson) : []; + return proofs.map((proof: { amount: string | number }) => ({ + ...proof, + amount: deserializeAmount(proof.amount), + })); +} diff --git a/packages/sqlite-bun/src/schema.ts b/packages/sqlite-bun/src/schema.ts index 1adf2477..f71518d8 100644 --- a/packages/sqlite-bun/src/schema.ts +++ b/packages/sqlite-bun/src/schema.ts @@ -62,6 +62,31 @@ async function addSendOperationMethodColumns(db: SqliteDb): Promise { } } +async function backfillSendOperationTokensFromHistory(db: SqliteDb): Promise { + await db.run(` + UPDATE coco_cashu_send_operations + SET tokenJson = ( + SELECT h.tokenJson + FROM coco_cashu_history h + WHERE h.type = 'send' + AND h.operationId = coco_cashu_send_operations.id + AND h.mintUrl = coco_cashu_send_operations.mintUrl + AND h.tokenJson IS NOT NULL + ORDER BY h.createdAt DESC, h.id DESC + LIMIT 1 + ) + WHERE tokenJson IS NULL + AND EXISTS ( + SELECT 1 + FROM coco_cashu_history h + WHERE h.type = 'send' + AND h.operationId = coco_cashu_send_operations.id + AND h.mintUrl = coco_cashu_send_operations.mintUrl + AND h.tokenJson IS NOT NULL + ) + `); +} + async function migrateAmountColumnsToText(db: SqliteDb): Promise { if (await tableExists(db, 'coco_cashu_proofs')) { await db.exec(` @@ -948,6 +973,28 @@ const MIGRATIONS: readonly Migration[] = [ id: '024_amount_columns_text', run: migrateAmountColumnsToText, }, + { + id: '025_history_projection_indexes', + sql: ` + CREATE INDEX IF NOT EXISTS idx_coco_cashu_send_operations_createdAt + ON coco_cashu_send_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_melt_operations_createdAt + ON coco_cashu_melt_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_createdAt + ON coco_cashu_mint_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_receive_operations_createdAt + ON coco_cashu_receive_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_history_createdAt + ON coco_cashu_history(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_history_type_operation + ON coco_cashu_history(type, operationId) + WHERE operationId IS NOT NULL; + `, + }, + { + id: '026_backfill_send_operation_tokens', + run: backfillSendOperationTokensFromHistory, + }, ]; // Export for testing diff --git a/packages/sqlite-bun/src/test/schema.test.ts b/packages/sqlite-bun/src/test/schema.test.ts index b3731e41..6c2d6b2b 100644 --- a/packages/sqlite-bun/src/test/schema.test.ts +++ b/packages/sqlite-bun/src/test/schema.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; // @ts-ignore bun:sqlite types are provided by the runtime in this workspace. import { Database } from 'bun:sqlite'; import { Amount } from '@cashu/coco-core'; -import { SqliteDb, ensureSchemaUpTo } from '../index.ts'; +import { SqliteDb, SqliteHistoryRepository, ensureSchemaUpTo } from '../index.ts'; import { SqliteMintOperationRepository } from '../repositories/MintOperationRepository.ts'; const RECEIVE_OPERATIONS_SQL = ` @@ -40,6 +40,54 @@ async function getColumnNames(db: SqliteDb, tableName: string): Promise row.name); } +const LEGACY_SEND_TOKEN = { + mint: 'https://mint.test', + proofs: [{ id: 'keyset-1', amount: '100', secret: 'send-secret', C: 'C_send' }], + unit: 'sat', +}; + +async function seedSendOperationWithLegacyToken(db: SqliteDb): Promise { + await db.run( + `INSERT INTO coco_cashu_send_operations + (id, mintUrl, amount, state, createdAt, updatedAt, error, method, methodDataJson, + needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, tokenJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'send-op-token-backfill', + 'https://mint.test', + '100', + 'pending', + 2, + 3, + null, + 'default', + '{}', + 0, + '0', + '100', + '["secret-1"]', + null, + null, + ], + ); + + await db.run( + `INSERT INTO coco_cashu_history + (mintUrl, type, unit, amount, createdAt, state, tokenJson, operationId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'https://mint.test', + 'send', + 'sat', + '100', + 1_000, + 'pending', + JSON.stringify(LEGACY_SEND_TOKEN), + 'send-op-token-backfill', + ], + ); +} + describe('sqlite-bun schema migrations', () => { let database: Database; let db: SqliteDb; @@ -197,4 +245,33 @@ describe('sqlite-bun schema migrations', () => { expect.arrayContaining(['method', 'methodDataJson']), ); }); + + it('backfills send operation tokens from matching legacy history rows', async () => { + await ensureSchemaUpTo(db, '026_backfill_send_operation_tokens'); + await seedSendOperationWithLegacyToken(db); + + await ensureSchemaUpTo(db); + + const row = await db.get<{ tokenJson: string | null }>( + `SELECT tokenJson FROM coco_cashu_send_operations WHERE id = ?`, + ['send-op-token-backfill'], + ); + expect(row?.tokenJson).toBe(JSON.stringify(LEGACY_SEND_TOKEN)); + + const repository = new SqliteHistoryRepository(db); + const history = await repository.getPaginatedHistoryEntries(10, 0); + + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + id: 'send:send-op-token-backfill', + source: 'operation', + type: 'send', + operationId: 'send-op-token-backfill', + token: { + mint: 'https://mint.test', + unit: 'sat', + proofs: [{ amount: Amount.from(100), secret: 'send-secret' }], + }, + }); + }); }); diff --git a/packages/sqlite3/src/repositories/HistoryRepository.ts b/packages/sqlite3/src/repositories/HistoryRepository.ts index 73ee722a..1ecdda1d 100644 --- a/packages/sqlite3/src/repositories/HistoryRepository.ts +++ b/packages/sqlite3/src/repositories/HistoryRepository.ts @@ -1,38 +1,222 @@ import type { HistoryEntry, HistoryRepository, - MintHistoryEntry, - MeltHistoryEntry, - ReceiveHistoryEntry, - ReceiveHistoryState, - SendHistoryEntry, - SendHistoryState, + HistoryType, + LegacyHistoryRowInput, +} from '@cashu/coco-core'; +import type { Token } from '@cashu/cashu-ts'; +import { + deserializeAmount, + deserializeToken, + operationHistoryId, + parseHistoryEntryId, + projectLegacyHistoryRow, } from '@cashu/coco-core'; -import { deserializeAmount, deserializeToken, serializeAmount } from '@cashu/coco-core'; import { SqliteDb } from '../db.ts'; -type MintQuoteState = MintHistoryEntry['state']; -type MeltQuoteState = MeltHistoryEntry['state']; -type ReceiveToken = NonNullable; -type SendToken = NonNullable; - -type Row = { - id: number; +type HistoryProjectionRow = { + source: 'operation' | 'legacy'; + id: string; + legacyHistoryId: string | null; + type: HistoryType; mintUrl: string; - type: 'mint' | 'melt' | 'send' | 'receive'; - unit: string; + unit: string | null; amount: string | number; createdAt: number; + updatedAt: number; + state: string; quoteId: string | null; - state: string | null; paymentRequest: string | null; tokenJson: string | null; + inputProofsJson: string | null; metadata: string | null; operationId: string | null; + remoteState: string | null; + error: string | null; }; -function parseToken(tokenJson: string | null): TToken | undefined { - return tokenJson ? (deserializeToken(JSON.parse(tokenJson)) as TToken | undefined) : undefined; +const projectionSelect = ` + SELECT * + FROM ( + SELECT + 'operation' AS source, + 'send:' || id AS id, + NULL AS legacyHistoryId, + 'send' AS type, + mintUrl, + 'sat' AS unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + NULL AS quoteId, + NULL AS paymentRequest, + tokenJson, + NULL AS inputProofsJson, + NULL AS metadata, + id AS operationId, + NULL AS remoteState, + error + FROM coco_cashu_send_operations + WHERE state != 'init' + + UNION ALL + + SELECT + 'operation' AS source, + 'melt:' || id AS id, + NULL AS legacyHistoryId, + 'melt' AS type, + mintUrl, + COALESCE(unit, 'sat') AS unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + quoteId, + NULL AS paymentRequest, + NULL AS tokenJson, + NULL AS inputProofsJson, + NULL AS metadata, + id AS operationId, + NULL AS remoteState, + error + FROM coco_cashu_melt_operations + WHERE state IN ('prepared', 'executing', 'pending', 'finalized', 'rolling_back', 'rolled_back') + + UNION ALL + + SELECT + 'operation' AS source, + 'mint:' || id AS id, + NULL AS legacyHistoryId, + 'mint' AS type, + mintUrl, + unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + quoteId, + request AS paymentRequest, + NULL AS tokenJson, + NULL AS inputProofsJson, + NULL AS metadata, + id AS operationId, + lastObservedRemoteState AS remoteState, + error + FROM coco_cashu_mint_operations + WHERE state != 'init' + + UNION ALL + + SELECT + 'operation' AS source, + 'receive:' || id AS id, + NULL AS legacyHistoryId, + 'receive' AS type, + mintUrl, + COALESCE(unit, 'sat') AS unit, + amount, + createdAt * 1000 AS createdAt, + updatedAt * 1000 AS updatedAt, + state, + NULL AS quoteId, + NULL AS paymentRequest, + NULL AS tokenJson, + inputProofsJson, + NULL AS metadata, + id AS operationId, + NULL AS remoteState, + error + FROM coco_cashu_receive_operations + WHERE state IN ('finalized', 'rolled_back') + + UNION ALL + + SELECT + 'legacy' AS source, + 'legacy:' || h.id AS id, + CAST(h.id AS TEXT) AS legacyHistoryId, + h.type, + h.mintUrl, + h.unit, + h.amount, + h.createdAt, + h.createdAt AS updatedAt, + COALESCE(h.state, '') AS state, + h.quoteId, + h.paymentRequest, + h.tokenJson, + NULL AS inputProofsJson, + h.metadata, + h.operationId, + NULL AS remoteState, + NULL AS error + FROM coco_cashu_history h + WHERE NOT ( + h.operationId IS NOT NULL AND EXISTS ( + SELECT 1 FROM ( + SELECT 'send' AS type, id AS operationId + FROM coco_cashu_send_operations + WHERE state != 'init' + UNION ALL + SELECT 'melt' AS type, id AS operationId + FROM coco_cashu_melt_operations + WHERE state IN ( + 'prepared', + 'executing', + 'pending', + 'finalized', + 'rolling_back', + 'rolled_back' + ) + UNION ALL + SELECT 'mint' AS type, id AS operationId + FROM coco_cashu_mint_operations + WHERE state != 'init' + UNION ALL + SELECT 'receive' AS type, id AS operationId + FROM coco_cashu_receive_operations + WHERE state IN ('finalized', 'rolled_back') + ) op + WHERE op.type = h.type AND op.operationId = h.operationId + ) + ) + AND NOT ( + h.operationId IS NULL + AND h.type IN ('mint', 'melt') + AND h.quoteId IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM ( + SELECT 'mint' AS type, mintUrl, quoteId + FROM coco_cashu_mint_operations + WHERE state != 'init' + UNION ALL + SELECT 'melt' AS type, mintUrl, quoteId + FROM coco_cashu_melt_operations + WHERE state IN ( + 'prepared', + 'executing', + 'pending', + 'finalized', + 'rolling_back', + 'rolled_back' + ) + ) opq + WHERE opq.type = h.type AND opq.mintUrl = h.mintUrl AND opq.quoteId = h.quoteId + ) + ) + ) +`; + +function parseToken(tokenJson: string | null): Token | undefined { + return tokenJson ? deserializeToken(JSON.parse(tokenJson)) : undefined; +} + +function parseMetadata(metadata: string | null): Record | undefined { + return metadata ? JSON.parse(metadata) : undefined; } export class SqliteHistoryRepository implements HistoryRepository { @@ -43,345 +227,128 @@ export class SqliteHistoryRepository implements HistoryRepository { } async getPaginatedHistoryEntries(limit: number, offset: number): Promise { - const rows = await this.db.all( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history + const rows = await this.db.all( + `${projectionSelect} ORDER BY createdAt DESC, id DESC LIMIT ? OFFSET ?`, [limit, offset], ); - return rows.map((r) => this.rowToEntry(r)); - } - - async getHistoryEntryById(id: string): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE id = ?`, - [id], - ); - if (!row) return null; - return this.rowToEntry(row); - } - - async addHistoryEntry(history: Omit): Promise { - const baseParams = [ - history.mintUrl, - history.type, - history.unit, - serializeAmount(history.amount), - history.createdAt, - ]; - - let quoteId: string | null = null; - let state: string | null = null; - let paymentRequest: string | null = null; - let tokenJson: string | null = null; - let metadata: string | null = history.metadata ? JSON.stringify(history.metadata) : null; - let operationId: string | null = null; - - switch (history.type) { - case 'mint': { - const h = history as Omit; - quoteId = h.quoteId; - state = h.state; - paymentRequest = h.paymentRequest; - operationId = h.operationId ?? null; - break; - } - case 'melt': { - const h = history as Omit; - quoteId = h.quoteId; - state = h.state; - operationId = h.operationId ?? null; - break; - } - case 'send': { - const h = history as Omit; - tokenJson = h.token ? JSON.stringify(h.token as SendToken) : null; - operationId = h.operationId; - state = h.state; - break; - } - case 'receive': { - const h = history as Omit; - tokenJson = h.token ? JSON.stringify(h.token as ReceiveToken) : null; - operationId = h.operationId ?? null; - state = h.state; - break; - } - } - - const result = await this.db.run( - `INSERT INTO coco_cashu_history (mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [...baseParams, quoteId, state, paymentRequest, tokenJson, metadata, operationId], - ); - const id = result.lastID; - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE id = ?`, - [id], - ); - if (!row) throw new Error('History insert failed to return row'); - return this.rowToEntry(row); - } - - async getMintHistoryEntry(mintUrl: string, quoteId: string): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'mint' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, quoteId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'mint' ? entry : null; - } - - async getMeltHistoryEntry(mintUrl: string, quoteId: string): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'melt' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, quoteId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'melt' ? entry : null; - } - - async getSendHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'send' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, operationId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'send' ? entry : null; - } - async getReceiveHistoryEntry( - mintUrl: string, - operationId: string, - ): Promise { - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'receive' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [mintUrl, operationId], - ); - if (!row) return null; - const entry = this.rowToEntry(row); - return entry.type === 'receive' ? entry : null; + return rows.map(rowToEntry); } - async updateHistoryEntry(history: Omit): Promise { - let state: string | null = null; - let paymentRequest: string | null = null; - let tokenJson: string | null = null; - - if (history.type === 'mint') { - const h = history as Omit; - if (!h.quoteId) throw new Error('quoteId required for mint entry'); - state = h.state; - paymentRequest = h.paymentRequest; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, paymentRequest = ?, metadata = ? - , operationId = ? - WHERE mintUrl = ? AND quoteId = ? AND type = 'mint'`, - [ - history.unit, - serializeAmount(history.amount), - state, - paymentRequest, - history.metadata ? JSON.stringify(history.metadata) : null, - h.operationId ?? null, - history.mintUrl, - h.quoteId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'mint' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, h.quoteId], - ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else if (history.type === 'melt') { - const h = history as Omit; - if (!h.quoteId) throw new Error('quoteId required for melt entry'); - state = h.state; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, metadata = ?, operationId = ? - WHERE mintUrl = ? AND quoteId = ? AND type = 'melt'`, - [ - history.unit, - serializeAmount(history.amount), - state, - history.metadata ? JSON.stringify(history.metadata) : null, - h.operationId ?? null, - history.mintUrl, - h.quoteId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ? AND type = 'melt' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, h.quoteId], - ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else if (history.type === 'send') { - const h = history as Omit; - if (!h.operationId) throw new Error('operationId required for send entry'); - state = h.state; - tokenJson = h.token ? JSON.stringify(h.token as SendToken) : null; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, tokenJson = ?, metadata = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'send'`, - [ - history.unit, - serializeAmount(history.amount), - state, - tokenJson, - history.metadata ? JSON.stringify(history.metadata) : null, - history.mintUrl, - h.operationId, - ], - ); - - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'send' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, h.operationId], - ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else if (history.type === 'receive') { - const h = history as Omit; - if (!h.operationId) throw new Error('operationId required for receive entry'); - state = h.state; - tokenJson = h.token ? JSON.stringify(h.token as ReceiveToken) : null; - - await this.db.run( - `UPDATE coco_cashu_history SET unit = ?, amount = ?, state = ?, tokenJson = ?, metadata = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'receive'`, - [ - history.unit, - serializeAmount(history.amount), - state, - tokenJson, - history.metadata ? JSON.stringify(history.metadata) : null, - history.mintUrl, - h.operationId, - ], - ); + async getHistoryEntryById(id: string): Promise { + const parsed = parseHistoryEntryId(id); + if (!parsed) return null; - const row = await this.db.get( - `SELECT id, mintUrl, type, unit, amount, createdAt, quoteId, state, paymentRequest, tokenJson, metadata, operationId - FROM coco_cashu_history WHERE mintUrl = ? AND operationId = ? AND type = 'receive' - ORDER BY createdAt DESC, id DESC LIMIT 1`, - [history.mintUrl, h.operationId], + if (parsed.source === 'legacy') { + const row = await this.db.get( + `${projectionSelect} + WHERE source = 'legacy' AND legacyHistoryId = ? + LIMIT 1`, + [parsed.legacyHistoryId], ); - if (!row) throw new Error('Updated history entry not found'); - return this.rowToEntry(row); - } else { - throw new Error(`Unsupported history entry type: ${String((history as HistoryEntry).type)}`); + return row ? rowToEntry(row) : null; } - } - - async updateSendHistoryState( - mintUrl: string, - operationId: string, - state: SendHistoryState, - ): Promise { - await this.db.run( - `UPDATE coco_cashu_history SET state = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'send'`, - [state, mintUrl, operationId], - ); - } - async updateReceiveHistoryState( - mintUrl: string, - operationId: string, - state: ReceiveHistoryState, - ): Promise { - await this.db.run( - `UPDATE coco_cashu_history SET state = ? - WHERE mintUrl = ? AND operationId = ? AND type = 'receive'`, - [state, mintUrl, operationId], + const row = await this.db.get( + `${projectionSelect} + WHERE source = 'operation' AND type = ? AND operationId = ? + LIMIT 1`, + [parsed.type, parsed.operationId], ); + return row ? rowToEntry(row) : null; } +} - async deleteHistoryEntry(mintUrl: string, quoteId: string): Promise { - await this.db.run('DELETE FROM coco_cashu_history WHERE mintUrl = ? AND quoteId = ?', [ - mintUrl, - quoteId, - ]); +function rowToEntry(row: HistoryProjectionRow): HistoryEntry { + if (row.source === 'legacy') { + return projectLegacyHistoryRow(rowToLegacyInput(row)); } - private rowToEntry(row: Row): HistoryEntry { - const base = { - id: String(row.id), - createdAt: row.createdAt, - mintUrl: row.mintUrl, - unit: row.unit, - metadata: row.metadata ? JSON.parse(row.metadata) : undefined, - } as const; + const base = { + id: operationHistoryId(row.type, row.operationId ?? ''), + source: 'operation' as const, + type: row.type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + mintUrl: row.mintUrl, + unit: row.unit ?? 'sat', + operationId: row.operationId ?? '', + amount: deserializeAmount(row.amount), + state: row.state, + ...(row.error ? { error: row.error } : {}), + }; - if (row.type === 'mint') { + switch (row.type) { + case 'mint': return { ...base, type: 'mint', - paymentRequest: row.paymentRequest ?? '', quoteId: row.quoteId ?? '', - operationId: row.operationId ?? undefined, - state: (row.state ?? 'UNPAID') as MintQuoteState, - amount: deserializeAmount(row.amount), - }; - } - if (row.type === 'melt') { + paymentRequest: row.paymentRequest ?? '', + state: row.state as HistoryEntry['state'], + ...(row.remoteState ? { remoteState: row.remoteState } : {}), + } as HistoryEntry; + case 'melt': return { ...base, type: 'melt', quoteId: row.quoteId ?? '', - operationId: row.operationId ?? undefined, - state: (row.state ?? 'UNPAID') as MeltQuoteState, - amount: deserializeAmount(row.amount), - }; - } - if (row.type === 'send') { + state: row.state as HistoryEntry['state'], + } as HistoryEntry; + case 'send': { + const token = parseToken(row.tokenJson); + return { ...base, type: 'send', - amount: deserializeAmount(row.amount), - operationId: row.operationId ?? '', - state: (row.state ?? 'pending') as SendHistoryState, - token: parseToken(row.tokenJson), - }; + unit: token?.unit ?? base.unit, + state: row.state as HistoryEntry['state'], + ...(token ? { token } : {}), + } as HistoryEntry; } - const token = parseToken(row.tokenJson); - return { - ...base, - type: 'receive', - amount: deserializeAmount(row.amount), - operationId: row.operationId ?? undefined, - state: (row.state ?? 'finalized') as ReceiveHistoryState, - token, - } satisfies HistoryEntry; + case 'receive': + return { + ...base, + type: 'receive', + state: row.state as HistoryEntry['state'], + ...(row.state === 'finalized' + ? { + token: { + mint: row.mintUrl, + proofs: parseTokenProofs(row.inputProofsJson), + unit: row.unit ?? 'sat', + }, + } + : {}), + } as HistoryEntry; } } + +function rowToLegacyInput(row: HistoryProjectionRow): LegacyHistoryRowInput { + return { + legacyHistoryId: row.legacyHistoryId ?? row.id.slice('legacy:'.length), + type: row.type, + createdAt: row.createdAt, + mintUrl: row.mintUrl, + unit: row.unit ?? 'sat', + amount: deserializeAmount(row.amount), + quoteId: row.quoteId, + state: row.state || null, + paymentRequest: row.paymentRequest, + token: parseToken(row.tokenJson) as LegacyHistoryRowInput['token'], + metadata: parseMetadata(row.metadata), + operationId: row.operationId, + }; +} + +function parseTokenProofs( + inputProofsJson: string | null, +): NonNullable['token']>['proofs'] { + const proofs = inputProofsJson ? JSON.parse(inputProofsJson) : []; + return proofs.map((proof: { amount: string | number }) => ({ + ...proof, + amount: deserializeAmount(proof.amount), + })); +} diff --git a/packages/sqlite3/src/schema.ts b/packages/sqlite3/src/schema.ts index 1adf2477..f71518d8 100644 --- a/packages/sqlite3/src/schema.ts +++ b/packages/sqlite3/src/schema.ts @@ -62,6 +62,31 @@ async function addSendOperationMethodColumns(db: SqliteDb): Promise { } } +async function backfillSendOperationTokensFromHistory(db: SqliteDb): Promise { + await db.run(` + UPDATE coco_cashu_send_operations + SET tokenJson = ( + SELECT h.tokenJson + FROM coco_cashu_history h + WHERE h.type = 'send' + AND h.operationId = coco_cashu_send_operations.id + AND h.mintUrl = coco_cashu_send_operations.mintUrl + AND h.tokenJson IS NOT NULL + ORDER BY h.createdAt DESC, h.id DESC + LIMIT 1 + ) + WHERE tokenJson IS NULL + AND EXISTS ( + SELECT 1 + FROM coco_cashu_history h + WHERE h.type = 'send' + AND h.operationId = coco_cashu_send_operations.id + AND h.mintUrl = coco_cashu_send_operations.mintUrl + AND h.tokenJson IS NOT NULL + ) + `); +} + async function migrateAmountColumnsToText(db: SqliteDb): Promise { if (await tableExists(db, 'coco_cashu_proofs')) { await db.exec(` @@ -948,6 +973,28 @@ const MIGRATIONS: readonly Migration[] = [ id: '024_amount_columns_text', run: migrateAmountColumnsToText, }, + { + id: '025_history_projection_indexes', + sql: ` + CREATE INDEX IF NOT EXISTS idx_coco_cashu_send_operations_createdAt + ON coco_cashu_send_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_melt_operations_createdAt + ON coco_cashu_melt_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_mint_operations_createdAt + ON coco_cashu_mint_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_receive_operations_createdAt + ON coco_cashu_receive_operations(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_history_createdAt + ON coco_cashu_history(createdAt DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coco_cashu_history_type_operation + ON coco_cashu_history(type, operationId) + WHERE operationId IS NOT NULL; + `, + }, + { + id: '026_backfill_send_operation_tokens', + run: backfillSendOperationTokensFromHistory, + }, ]; // Export for testing diff --git a/packages/sqlite3/src/test/schema.test.ts b/packages/sqlite3/src/test/schema.test.ts index 119ffecb..95f0597b 100644 --- a/packages/sqlite3/src/test/schema.test.ts +++ b/packages/sqlite3/src/test/schema.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import Database from 'better-sqlite3'; -import { SqliteDb, ensureSchemaUpTo } from '../index.ts'; +import { Amount } from '@cashu/coco-core'; +import { SqliteDb, SqliteHistoryRepository, ensureSchemaUpTo } from '../index.ts'; const RECEIVE_OPERATIONS_SQL = ` CREATE TABLE IF NOT EXISTS coco_cashu_receive_operations ( @@ -34,6 +35,54 @@ async function getColumnNames(db: SqliteDb, tableName: string): Promise row.name); } +const LEGACY_SEND_TOKEN = { + mint: 'https://mint.test', + proofs: [{ id: 'keyset-1', amount: '100', secret: 'send-secret', C: 'C_send' }], + unit: 'sat', +}; + +async function seedSendOperationWithLegacyToken(db: SqliteDb): Promise { + await db.run( + `INSERT INTO coco_cashu_send_operations + (id, mintUrl, amount, state, createdAt, updatedAt, error, method, methodDataJson, + needsSwap, fee, inputAmount, inputProofSecretsJson, outputDataJson, tokenJson) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'send-op-token-backfill', + 'https://mint.test', + '100', + 'pending', + 2, + 3, + null, + 'default', + '{}', + 0, + '0', + '100', + '["secret-1"]', + null, + null, + ], + ); + + await db.run( + `INSERT INTO coco_cashu_history + (mintUrl, type, unit, amount, createdAt, state, tokenJson, operationId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + 'https://mint.test', + 'send', + 'sat', + '100', + 1_000, + 'pending', + JSON.stringify(LEGACY_SEND_TOKEN), + 'send-op-token-backfill', + ], + ); +} + describe('sqlite3 schema migrations', () => { let database: Database.Database; let db: SqliteDb; @@ -128,4 +177,33 @@ describe('sqlite3 schema migrations', () => { expect.arrayContaining(['method', 'methodDataJson']), ); }); + + it('backfills send operation tokens from matching legacy history rows', async () => { + await ensureSchemaUpTo(db, '026_backfill_send_operation_tokens'); + await seedSendOperationWithLegacyToken(db); + + await ensureSchemaUpTo(db); + + const row = await db.get<{ tokenJson: string | null }>( + `SELECT tokenJson FROM coco_cashu_send_operations WHERE id = ?`, + ['send-op-token-backfill'], + ); + expect(row?.tokenJson).toBe(JSON.stringify(LEGACY_SEND_TOKEN)); + + const repository = new SqliteHistoryRepository(db); + const history = await repository.getPaginatedHistoryEntries(10, 0); + + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ + id: 'send:send-op-token-backfill', + source: 'operation', + type: 'send', + operationId: 'send-op-token-backfill', + token: { + mint: 'https://mint.test', + unit: 'sat', + proofs: [{ amount: Amount.from(100), secret: 'send-secret' }], + }, + }); + }); });