mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
282 lines
6.3 KiB
TypeScript
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);
|
|
}
|