Files
thrilltrack-explorer/src-old/lib/idempotencyLifecycle.ts

282 lines
6.3 KiB
TypeScript

/**
* 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<IdempotencyDB> | null = null;
async function getDB(): Promise<IDBPDatabase<IdempotencyDB>> {
if (dbInstance) return dbInstance;
dbInstance = await openDB<IdempotencyDB>(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<IdempotencyRecord> {
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<void> {
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<IdempotencyRecord | null> {
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<boolean> {
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<IdempotencyRecord[]> {
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<IdempotencyRecord[]> {
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<number> {
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);
}