/** * Idempotency Key Lifecycle Management * * Tracks idempotency keys through their lifecycle: * - pending: Key generated, request not yet sent * - processing: Request in progress * - completed: Request succeeded * - failed: Request failed * - expired: Key expired (24h window) * * Part of Sacred Pipeline Phase 4: Transaction Resilience */ import { openDB, DBSchema, IDBPDatabase } from 'idb'; import { logger } from './logger'; export type IdempotencyStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired'; export interface IdempotencyRecord { key: string; action: 'approval' | 'rejection' | 'retry'; submissionId: string; itemIds: string[]; userId: string; status: IdempotencyStatus; createdAt: number; updatedAt: number; expiresAt: number; attempts: number; lastError?: string; completedAt?: number; } interface IdempotencyDB extends DBSchema { idempotency_keys: { key: string; value: IdempotencyRecord; indexes: { 'by-submission': string; 'by-status': IdempotencyStatus; 'by-expiry': number; }; }; } const DB_NAME = 'thrillwiki-idempotency'; const DB_VERSION = 1; const STORE_NAME = 'idempotency_keys'; const KEY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours let dbInstance: IDBPDatabase | null = null; async function getDB(): Promise> { if (dbInstance) return dbInstance; dbInstance = await openDB(DB_NAME, DB_VERSION, { upgrade(db) { if (!db.objectStoreNames.contains(STORE_NAME)) { const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' }); store.createIndex('by-submission', 'submissionId'); store.createIndex('by-status', 'status'); store.createIndex('by-expiry', 'expiresAt'); } }, }); return dbInstance; } /** * Register a new idempotency key */ export async function registerIdempotencyKey( key: string, action: IdempotencyRecord['action'], submissionId: string, itemIds: string[], userId: string ): Promise { const db = await getDB(); const now = Date.now(); const record: IdempotencyRecord = { key, action, submissionId, itemIds, userId, status: 'pending', createdAt: now, updatedAt: now, expiresAt: now + KEY_TTL_MS, attempts: 0, }; await db.add(STORE_NAME, record); logger.info('[IdempotencyLifecycle] Registered key', { key, action, submissionId, itemCount: itemIds.length, }); return record; } /** * Update idempotency key status */ export async function updateIdempotencyStatus( key: string, status: IdempotencyStatus, error?: string ): Promise { const db = await getDB(); const record = await db.get(STORE_NAME, key); if (!record) { logger.warn('[IdempotencyLifecycle] Key not found for update', { key, status }); return; } const now = Date.now(); record.status = status; record.updatedAt = now; if (status === 'processing') { record.attempts += 1; } if (status === 'completed') { record.completedAt = now; } if (status === 'failed' && error) { record.lastError = error; } await db.put(STORE_NAME, record); logger.info('[IdempotencyLifecycle] Updated key status', { key, status, attempts: record.attempts, }); } /** * Get idempotency record by key */ export async function getIdempotencyRecord(key: string): Promise { const db = await getDB(); const record = await db.get(STORE_NAME, key); // Check if expired if (record && record.expiresAt < Date.now()) { await updateIdempotencyStatus(key, 'expired'); return { ...record, status: 'expired' }; } return record || null; } /** * Check if key exists and is valid */ export async function isIdempotencyKeyValid(key: string): Promise { const record = await getIdempotencyRecord(key); if (!record) return false; if (record.status === 'expired') return false; if (record.expiresAt < Date.now()) return false; return true; } /** * Get all keys for a submission */ export async function getSubmissionIdempotencyKeys( submissionId: string ): Promise { const db = await getDB(); const index = db.transaction(STORE_NAME).store.index('by-submission'); return await index.getAll(submissionId); } /** * Get keys by status */ export async function getIdempotencyKeysByStatus( status: IdempotencyStatus ): Promise { const db = await getDB(); const index = db.transaction(STORE_NAME).store.index('by-status'); return await index.getAll(status); } /** * Clean up expired keys */ export async function cleanupExpiredKeys(): Promise { const db = await getDB(); const now = Date.now(); const tx = db.transaction(STORE_NAME, 'readwrite'); const index = tx.store.index('by-expiry'); let deletedCount = 0; // Get all expired keys for await (const cursor of index.iterate()) { if (cursor.value.expiresAt < now) { await cursor.delete(); deletedCount++; } } await tx.done; if (deletedCount > 0) { logger.info('[IdempotencyLifecycle] Cleaned up expired keys', { deletedCount }); } return deletedCount; } /** * Get idempotency statistics */ export async function getIdempotencyStats(): Promise<{ total: number; pending: number; processing: number; completed: number; failed: number; expired: number; }> { const db = await getDB(); const all = await db.getAll(STORE_NAME); const now = Date.now(); const stats = { total: all.length, pending: 0, processing: 0, completed: 0, failed: 0, expired: 0, }; all.forEach(record => { // Mark as expired if TTL passed if (record.expiresAt < now) { stats.expired++; } else { stats[record.status]++; } }); return stats; } /** * Auto-cleanup: Run periodically to remove expired keys */ export function startAutoCleanup(intervalMinutes: number = 60): () => void { const intervalId = setInterval(async () => { try { await cleanupExpiredKeys(); } catch (error) { logger.error('[IdempotencyLifecycle] Auto-cleanup failed', { error }); } }, intervalMinutes * 60 * 1000); // Run immediately on start cleanupExpiredKeys(); // Return cleanup function return () => clearInterval(intervalId); }