VetNet-LCPS / vetcopilot-app /src /utils /offlineCache.ts
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
8.2 kB
/**
* Offline SOAP Cache Utility
*
* Uses IndexedDB to cache consultation data (consultation info + SOAP + patient)
* locally for offline access. Supports up to 50 entries with automatic eviction
* of the oldest entries when the limit is exceeded.
*/
import type { SoapData } from '../api'
// ================================================================
// Types
// ================================================================
/** Consultation summary stored alongside SOAP and patient data */
export interface CachedConsultationInfo {
id: string
animal_name: string
species: string
breed: string
age: string
sex: string
weight: string
doctor_name: string
status: string
created_at: string
}
/** Patient data stored in the cache */
export interface CachedPatientInfo {
animal_name: string
species: string
breed: string
age: string
weight: string
allergy_history: string
}
/** Full cache entry matching the shared record pattern */
export interface CachedConsultation {
/** Consultation ID (also the cache key) */
id: string
/** Consultation summary metadata */
consultation: CachedConsultationInfo
/** SOAP data (may be null if not yet generated) */
soap: SoapData | null
/** Patient information */
patient: CachedPatientInfo | null
/** Unix timestamp (ms) when this entry was cached */
cached_at: number
}
// ================================================================
// Constants
// ================================================================
const DB_NAME = 'vetcopilot_offline_cache'
const DB_VERSION = 1
const STORE_NAME = 'consultations'
const MAX_ENTRIES = 50
// ================================================================
// IndexedDB helpers
// ================================================================
/** Open (or create) the IndexedDB database */
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
// Create an index on cached_at for efficient eviction queries
store.createIndex('cached_at', 'cached_at', { unique: false })
}
}
request.onsuccess = (event) => {
resolve((event.target as IDBOpenDBRequest).result)
}
request.onerror = (event) => {
reject(
new Error(
`Failed to open IndexedDB: ${(event.target as IDBOpenDBRequest).error?.message ?? 'unknown error'}`,
),
)
}
request.onblocked = () => {
reject(new Error('IndexedDB blocked: close other tabs using this database'))
}
})
}
/** Perform a read-write transaction on the store and return its result */
async function withStore<T>(
mode: IDBTransactionMode,
callback: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
const db = await openDB()
return new Promise<T>((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, mode)
const store = transaction.objectStore(STORE_NAME)
const request = callback(store)
request.onsuccess = () => {
resolve(request.result)
}
request.onerror = () => {
reject(
new Error(
`IndexedDB request failed: ${request.error?.message ?? 'unknown error'}`,
),
)
}
// Close DB when transaction completes (or fails)
transaction.oncomplete = () => {
db.close()
}
transaction.onerror = () => {
db.close()
reject(
new Error(
`IndexedDB transaction failed: ${transaction.error?.message ?? 'unknown error'}`,
),
)
}
transaction.onabort = () => {
db.close()
reject(new Error('IndexedDB transaction aborted'))
}
})
}
// ================================================================
// Eviction
// ================================================================
/**
* Remove the oldest entries if the store exceeds MAX_ENTRIES.
* Uses the cached_at index to find and delete the oldest.
*/
async function evictOldestIfNeeded(): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const countRequest = store.count()
countRequest.onsuccess = () => {
const count = countRequest.result
if (count <= MAX_ENTRIES) {
db.close()
resolve()
return
}
const excess = count - MAX_ENTRIES
const index = store.index('cached_at')
const cursorRequest = index.openCursor()
let deleted = 0
cursorRequest.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor && deleted < excess) {
cursor.delete()
deleted++
cursor.continue()
}
// Cursor exhausted or enough deleted
}
}
countRequest.onerror = () => {
db.close()
reject(new Error('Failed to count cached entries'))
}
transaction.oncomplete = () => {
db.close()
resolve()
}
transaction.onerror = () => {
db.close()
reject(new Error('Eviction transaction failed'))
}
transaction.onabort = () => {
db.close()
reject(new Error('Eviction transaction aborted'))
}
})
}
// ================================================================
// Public API
// ================================================================
/**
* Cache a full consultation record (consultation info + SOAP + patient).
*
* If an entry with the same ID already exists, it will be replaced.
* After insertion, if total entries exceed MAX_ENTRIES (50), the oldest
* entries are automatically evicted.
*/
export async function cacheConsultation(
data: CachedConsultation,
): Promise<void> {
await withStore('readwrite', (store) => store.put(data))
await evictOldestIfNeeded()
}
/**
* Retrieve a single cached consultation by its ID.
* Returns null if the consultation is not cached.
*/
export async function getCachedConsultation(
id: string,
): Promise<CachedConsultation | null> {
try {
const result = await withStore<CachedConsultation | undefined>(
'readonly',
(store) => store.get(id),
)
return result ?? null
} catch {
return null
}
}
/**
* Retrieve all cached consultations ordered by cached_at descending (newest first).
*/
export async function getAllCachedConsultations(): Promise<CachedConsultation[]> {
try {
const db = await openDB()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly')
const store = transaction.objectStore(STORE_NAME)
const results: CachedConsultation[] = []
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
results.push(cursor.value as CachedConsultation)
cursor.continue()
} else {
// Sort newest first
results.sort((a, b) => b.cached_at - a.cached_at)
resolve(results)
}
}
request.onerror = () => {
reject(new Error('Failed to read all cached items'))
}
transaction.oncomplete = () => {
db.close()
}
transaction.onerror = () => {
db.close()
reject(new Error('Transaction failed'))
}
transaction.onabort = () => {
db.close()
reject(new Error('Transaction aborted'))
}
})
} catch {
return []
}
}
/**
* Remove a single cached consultation by its ID.
*/
export async function removeConsultation(id: string): Promise<void> {
await withStore('readwrite', (store) => store.delete(id))
}
/**
* Remove all cached consultations from the store.
*/
export async function clearAll(): Promise<void> {
await withStore('readwrite', (store) => store.clear())
}