import { FinancialBalance, User } from "../db/interfaces.ts"; import { BrainProgress, sqlCreateQuizConfig, sqlCreateQuizQuestion, sqlCreateQuizResult, sqlCreateUserRole, sqlDeleteBrainProgress, sqlDeleteQuizConfig, sqlDeleteQuizQuestion, sqlDeleteQuizQuestionsByQuiz, sqlDeleteQuizResult, sqlDeleteQuizResultsByQuiz, sqlDeleteSessionConfig, sqlDeleteStateKey, sqlDeleteUser, sqlDeleteUserPin, sqlDeleteUserRole, sqlDeleteUserTokens, sqlGetAllBrainProgress, sqlGetAllFinancialBalances, sqlGetAllQuizConfigs, sqlGetAllQuizQuestions, sqlGetAllQuizResults, sqlGetAllRoles, sqlGetAllSessionConfigs, sqlGetAllStateKeys, sqlGetAllUserPins, sqlGetAllUserRoles, sqlGetAllUsers, sqlGetAllUserTokens, sqlGetBrainProgressById, sqlGetFinancialBalancesByUser, sqlGetQuizConfig, sqlGetQuizQuestion, sqlGetQuizQuestionsByQuiz as _sqlGetQuizQuestionsByQuiz, sqlGetQuizResult, sqlGetQuizResultsByQuiz as _sqlGetQuizResultsByQuiz, sqlGetQuizResultsByUser as _sqlGetQuizResultsByUser, sqlGetSessionConfig, sqlGetStateKeys, sqlGetUser, sqlGetUserPin, sqlGetUserRoles, sqlGetUserTokens, sqlSetSessionConfig, sqlSetStateKey, sqlSetUserPin, sqlSetUserTokens, sqlUpdateQuizConfig, sqlUpdateQuizQuestion, sqlUpdateQuizResult, sqlUpdateSessionConfig as _sqlUpdateSessionConfig, sqlUpdateUserPin as _sqlUpdateUserPin, sqlUpsertBrainProgress, sqlUpsertFinancialBalance, sqlUpsertUser as _sqlUpsertUser, sqlUpsertUserFull, } from "../db/sqlUserRepository.ts"; export function handleDatabaseError(error: unknown): void { const errorMessage = error instanceof Error ? error.message : String(error); console.error("🔄 Database error detected. Triggering app reload..."); console.error("Error details:", errorMessage); const isDevelopment = Deno.env.get("DEVELOPMENT") === "true"; const restartCmd = isDevelopment ? "exec deno task dev" : "exec ./server"; const command = new Deno.Command("sh", { args: [ "-c", `lsof -ti:8080 | xargs kill -9 2>/dev/null || true; ${restartCmd}`, ], cwd: Deno.cwd(), stdin: "inherit", stdout: "inherit", stderr: "inherit", }); command.spawn(); Deno.exit(0); } interface KvAdapter { get(key: Deno.KvKey): Promise<{ value: T | null }>; set(key: Deno.KvKey, value: T): Promise; delete(key: Deno.KvKey): Promise; list( options?: { prefix?: Deno.KvKey }, ): AsyncIterable<{ key: Deno.KvKey; value: T }>; close(): Promise | void; } export class KvCacheStore { private memKv: KvAdapter; private fileKv: KvAdapter; constructor(memKv: KvAdapter, fileKv: KvAdapter) { this.memKv = memKv; this.fileKv = fileKv; } static async initialize(filePath: string): Promise { try { await Deno.mkdir(filePath, { recursive: true }); } catch (err) { if (!(err instanceof Deno.errors.AlreadyExists)) { console.error("Failed to create KV cache directory:", err); } } const memKv: KvAdapter = await Deno.openKv(":memory:") as KvAdapter; const fileKv = await Deno.openKv(`${filePath}/kv-cache`) as KvAdapter; for await (const entry of fileKv.list({ prefix: [] })) { await memKv.set(entry.key, entry.value); } return new KvCacheStore(memKv, fileKv); } async get(key: Deno.KvKey): Promise { try { const result = await this.memKv.get(key); if (result.value !== null) { return result.value; } const sqlResult = await this.fetchFromSql(key); if (sqlResult) { await this.memKv.set(key, sqlResult); await this.fileKv.set(key, sqlResult); return sqlResult; } return null; } catch (error) { console.error("KV/SQL get failed:", error); handleDatabaseError(error); return null; } } async set(key: Deno.KvKey, value: T): Promise { try { const existing = await this.get(key); if ( existing && typeof existing === "object" && existing !== null && "created_on" in existing ) { const createdOn = (existing as unknown as { created_on: number }).created_on; if (createdOn) { value = { ...value, created_on: createdOn, }; } } await this.memKv.set(key, value); await this.fileKv.set(key, value); await this.persistToSql(key, value); } catch (error) { console.error("KV/SQL set failed:", error); handleDatabaseError(error); } } async delete(key: Deno.KvKey): Promise { try { await this.memKv.delete(key); await this.fileKv.delete(key); this.deleteFromSql(key); } catch (error) { console.error("KV/SQL delete failed:", error); handleDatabaseError(error); } } async *list( options?: { prefix?: Deno.KvKey }, ): AsyncIterable<{ key: Deno.KvKey; value: T }> { const yieldedKeys = new Set(); for await (const entry of this.memKv.list(options ?? {})) { const keyStr = JSON.stringify(entry.key); if (!yieldedKeys.has(keyStr)) { yieldedKeys.add(keyStr); yield entry; } } for await (const entry of this.fileKv.list(options ?? {})) { const keyStr = JSON.stringify(entry.key); if (!yieldedKeys.has(keyStr)) { yieldedKeys.add(keyStr); yield entry; } } const prefix = options?.prefix; const namespace = prefix && prefix.length > 0 ? String(prefix[0]) : null; if (namespace) { const sqlEntries = await this.listFromSql(namespace); for (const entry of sqlEntries) { const keyStr = JSON.stringify(entry.key); if (!yieldedKeys.has(keyStr)) { if (prefix && !this.keyMatchesPrefix(entry.key, prefix)) { continue; } yieldedKeys.add(keyStr); yield entry; } } } } private keyMatchesPrefix(key: Deno.KvKey, prefix: Deno.KvKey): boolean { if (key.length < prefix.length) { return false; } for (let i = 0; i < prefix.length; i++) { if (key[i] !== prefix[i]) { return false; } } return true; } private async listFromSql( namespace: string, ): Promise> { const results: Array<{ key: Deno.KvKey; value: T }> = []; switch (namespace) { case "users": { const users = await sqlGetAllUsers(); for (const user of users) { results.push({ key: ["users", user.id], value: { id: user.id, name: user.name || "", email: user.email || "", phone: user.phone || "", address_1: user.address_1 || "", address_2: user.address_2 || "", city: user.city || "", state: user.state || "", zip: user.zip || "", country: user.country || "", user_type: user.user_type, onboarded_landlord: user.onboarded_landlord ?? false, onboarded_tenant: user.onboarded_tenant ?? false, email_verified: user.email_verified, banking_setup_complete: user.banking_setup_complete, onboarding_step: user.onboarding_step, encrypted_data: user.encrypted_data, started_on: user.started_on || Date.now(), updated_on: user.updated_on || Date.now(), } as T, }); } break; } case "roles": { const roles = await sqlGetAllRoles(); for (const role of roles) { results.push({ key: ["roles", role.id], value: { id: role.id, description: role.description || "", } as T, }); } break; } case "user_roles": { const userRoles = await sqlGetAllUserRoles(); const grouped = new Map(); for (const ur of userRoles) { const existing = grouped.get(ur.userId || "") || []; if (!ur.userId) { continue; } existing.push(ur.roleId || ""); grouped.set(ur.userId || "", existing); } for (const [userId, roleIds] of grouped) { results.push({ key: ["user_roles", userId], value: roleIds as T, }); } break; } case "state_keys": { const stateKeys = await sqlGetAllStateKeys(); for (const sk of stateKeys) { results.push({ key: ["state_keys", sk.state_id], value: { seal_key: sk.seal_key, state_id: sk.state_id, cookie_hash: sk.cookie_hash, created_on: sk.created_on, } as T, }); } break; } case "financial_balances": { const balances = await sqlGetAllFinancialBalances(); for (const balance of balances) { results.push({ key: ["financial_balances", balance.user_id || ""], value: balance as T, }); } break; } case "user_tokens": { const tokens = await sqlGetAllUserTokens(); for (const token of tokens) { results.push({ key: ["user_tokens", token.user_id || ""], value: { user_id: token.user_id, state_id: token.state_id, access_token: token.access_token, refresh_token: token.refresh_token, created_on: token.created_on, updated_on: token.updated_on, } as T, }); } break; } case "user_pins": { const pins = await sqlGetAllUserPins(); for (const pin of pins) { results.push({ key: ["user_pins", pin.user_id || ""], value: { user_id: pin.user_id, pin: pin.pin, created_on: pin.created_on, modified_on: pin.modified_on, } as T, }); } break; } case "session_configs": { const configs = await sqlGetAllSessionConfigs(); for (const config of configs) { results.push({ key: [ "session_configs", config.user_id || "", config.device_id || "", ], value: { device_id: config.device_id, user_id: config.user_id, other_data: config.other_data, active_date: config.active_date, quiz_configs: config.quiz_configs, active_route: config.active_route, page_data: config.page_data, active_quiz_index: config.active_quiz_index, active_question_index: config.active_question_index, } as T, }); } break; } case "quiz_configs": { const configs = await sqlGetAllQuizConfigs(); for (const config of configs) { results.push({ key: [ "quiz_configs", config.owner_id || "", config.quiz_config_id || "", ], value: { quiz_config_id: config.quiz_config_id, quiz_name: config.quiz_name, owner_id: config.owner_id, quiz_mode: config.quiz_mode, active_date: config.active_date, tab_name: config.tab_name, tab_index: config.tab_index, list_index: config.list_index, questions: config.questions, results: config.results, } as T, }); } break; } case "quiz_questions": { const questions = await sqlGetAllQuizQuestions(); for (const question of questions) { results.push({ key: ["quiz_questions", question.owner_id || "", question.id || ""], value: { id: question.id, quiz_id: question.quiz_id, owner_id: question.owner_id, language_1: question.language_1, language_2: question.language_2, text_1: question.text_1, audio_file_1: question.audio_file_1, text_2: question.text_2, audio_file_2: question.audio_file_2, order_num: question.order_num, background_file: question.background_file, background_image_data: question.background_image_data, video_file: question.video_file, video_file_data: question.video_file_data, } as T, }); } break; } case "quiz_results": { const quizResults = await sqlGetAllQuizResults(); for (const result of quizResults) { results.push({ key: ["quiz_results", result.user_id || "", result.id || ""], value: { id: result.id, quiz_config_id: result.quiz_config_id, user_id: result.user_id, success: result.success, total_time: result.total_time, help_count: result.help_count, completed_on: result.completed_on, } as T, }); } break; } case "brain_question_progress": { const brainProgress = await sqlGetAllBrainProgress(); for (const progress of brainProgress) { results.push({ key: [ "brain_question_progress", progress.user_id || "", progress.id || "", ], value: { id: progress.id, user_id: progress.user_id, quiz_id: progress.quiz_id, question_id: progress.question_id, current_mode: progress.current_mode, mode_index: progress.mode_index ?? 0, attempts: progress.attempts, first_try_successes: progress.first_try_successes, consecutive_first_try: progress.consecutive_first_try ?? 0, last_result: progress.last_result, next_due_at: progress.next_due_at, interval_days: progress.interval_days, ease_factor: progress.ease_factor, is_review_only: progress.is_review_only ?? false, created_on: progress.created_on, updated_on: progress.updated_on, } as T, }); } break; } default: break; } return results; } async close() { await this.memKv.close(); await this.fileKv.close(); } private async persistToSql(key: Deno.KvKey, value: unknown): Promise { const [namespace, id] = key; if (typeof namespace !== "string") { return; } switch (namespace) { case "users": { const user = value as User; sqlUpsertUserFull({ id: user.id, name: user.name ?? "", email: user.email ?? "", phone: user.phone ?? "", address_1: user.address_1 ?? "", address_2: user.address_2 ?? "", city: user.city ?? "", state: user.state ?? "", zip: user.zip ?? "", country: user.country ?? "", user_type: user.user_type ?? null, onboarded_landlord: user.onboarded_landlord ?? false, onboarded_tenant: user.onboarded_tenant ?? false, email_verified: user.email_verified ?? null, banking_setup_complete: user.banking_setup_complete ?? null, onboarding_step: user.onboarding_step ?? null, encrypted_data: user.encrypted_data ?? null, started_on: user.started_on ?? Date.now(), updated_on: user.updated_on ?? Date.now(), }); break; } case "roles": { const role = value as { id: string; description: string }; void role; break; } case "user_roles": { const roleIds = value as string[]; const userId = id as string; sqlDeleteUserRole(userId); for (const roleId of roleIds) { sqlCreateUserRole(userId, roleId); } break; } case "state_keys": { const stateKey = value as { seal_key: string; state_id: string; cookie_hash: string; created_on: number; }; sqlSetStateKey( stateKey.seal_key, stateKey.state_id, stateKey.cookie_hash, ); break; } case "financial_balances": { const balance = value as FinancialBalance; sqlUpsertFinancialBalance( balance.user_id || "", balance.balance || 0, balance.balance_date || 0, ); break; } case "user_tokens": { const token = value as { user_id: string; state_id: string; access_token: string; refresh_token: string; }; sqlSetUserTokens( token.user_id, token.state_id, token.access_token, token.refresh_token, ); break; } case "user_pins": { const userPin = value as { user_id: string; pin: string; created_on: number; modified_on: number; }; sqlSetUserPin(userPin.user_id, userPin.pin); break; } case "session_configs": { const sessionConfig = value as { device_id: string; user_id: string; other_data: string; active_date: number; quiz_configs: Array>; active_route: string; page_data: string; active_quiz_index: number; active_question_index: number; }; sqlSetSessionConfig( sessionConfig.device_id, sessionConfig.user_id, sessionConfig.other_data, sessionConfig.active_route, sessionConfig.page_data, sessionConfig.active_quiz_index, sessionConfig.active_question_index, sessionConfig.quiz_configs, ); break; } case "quiz_configs": { const quizConfig = value as { quiz_config_id: string; quiz_name: string; owner_id: string; quiz_mode: string; active_date: number; tab_name?: string; tab_index?: number; list_index?: number; questions: any[]; results: any[]; }; const existing = await sqlGetQuizConfig(quizConfig.quiz_config_id); if (existing) { await sqlUpdateQuizConfig(quizConfig.quiz_config_id, { quiz_name: quizConfig.quiz_name, owner_id: quizConfig.owner_id, quiz_mode: quizConfig.quiz_mode, active_date: quizConfig.active_date, tab_name: quizConfig.tab_name, tab_index: quizConfig.tab_index, list_index: quizConfig.list_index, questions: quizConfig.questions, results: quizConfig.results, }); } else { await sqlCreateQuizConfig( quizConfig.quiz_config_id, quizConfig.quiz_name, quizConfig.owner_id, quizConfig.quiz_mode, quizConfig.questions, quizConfig.results, quizConfig.tab_name, quizConfig.tab_index, quizConfig.list_index, ); } break; } case "quiz_questions": { const question = value as { id: string; quiz_id: string; owner_id: string; language_1: string; language_2: string; text_1: string; audio_file_1: string; text_2: string; audio_file_2: string; order_num: number; background_file: string; background_image_data: string; video_file: string; video_file_data: string; }; const existing = await sqlGetQuizQuestion(question.id); if (existing) { await sqlUpdateQuizQuestion(question.id, question); } else { await sqlCreateQuizQuestion( question.id, question.quiz_id, question.owner_id, question.language_1, question.language_2, question.text_1, question.audio_file_1, question.text_2, question.audio_file_2, question.order_num, question.background_file, question.background_image_data, question.video_file, question.video_file_data, ); } break; } case "quiz_results": { const result = value as { id: string; quiz_config_id: string; user_id: string; success: boolean; total_time: number; help_count: number; completed_on: number; }; const existing = await sqlGetQuizResult(result.id); if (existing) { await sqlUpdateQuizResult(result.id, { quiz_config_id: result.quiz_config_id, user_id: result.user_id, success: result.success, total_time: result.total_time, help_count: result.help_count, completed_on: result.completed_on, }); } else { await sqlCreateQuizResult( result.id, result.quiz_config_id, result.user_id, result.success, result.total_time, result.help_count, ); } break; } case "brain_question_progress": { const progress = value as BrainProgress; await sqlUpsertBrainProgress(progress); break; } default: break; } } private deleteFromSql(key: Deno.KvKey): void { const [namespace, id] = key; if (typeof namespace !== "string") { return; } switch (namespace) { case "users": sqlDeleteUser(id as string); break; case "user_roles": sqlDeleteUserRole(id as string); break; case "state_keys": sqlDeleteStateKey(id as string); break; case "user_tokens": sqlDeleteUserTokens(id as string); break; case "user_pins": sqlDeleteUserPin(id as string); break; case "session_configs": { const recordId = key[2] as string | undefined; if (recordId) sqlDeleteSessionConfig(recordId); break; } case "quiz_configs": { const recordId = key[2] as string | undefined; if (recordId) { sqlDeleteQuizConfig(recordId); sqlDeleteQuizQuestionsByQuiz(recordId); sqlDeleteQuizResultsByQuiz(recordId); } break; } case "quiz_questions": { const recordId = key[2] as string | undefined; if (recordId) sqlDeleteQuizQuestion(recordId); break; } case "quiz_results": { const recordId = key[2] as string | undefined; if (recordId) sqlDeleteQuizResult(recordId); break; } case "brain_question_progress": { const recordId = key[2] as string | undefined; if (recordId) sqlDeleteBrainProgress(recordId); break; } default: break; } } private async fetchFromSql(key: Deno.KvKey): Promise { const [namespace, id] = key; if (typeof namespace !== "string") { return null; } switch (namespace) { case "users": { const user = await sqlGetUser(id as string); if (!user) return null; return { id: user.id, name: user.name || "", email: user.email || "", phone: user.phone || "", address_1: user.address_1 || "", address_2: user.address_2 || "", city: user.city || "", state: user.state || "", zip: user.zip || "", country: user.country || "", user_type: user.user_type, onboarded_landlord: user.onboarded_landlord ?? false, onboarded_tenant: user.onboarded_tenant ?? false, email_verified: user.email_verified, banking_setup_complete: user.banking_setup_complete, onboarding_step: user.onboarding_step, encrypted_data: user.encrypted_data, started_on: user.started_on || Date.now(), updated_on: user.updated_on || Date.now(), } as T; } case "roles": { const roles = await sqlGetAllRoles(); const role = roles.find((r: { id: string; description: string }) => r.id === id); if (!role) return null; return { id: role.id, description: role.description || "", } as T; } case "user_roles": { const roles = await sqlGetUserRoles(id as string); if (!roles || roles.length === 0) return null; return roles.map((r: { roleId: string }) => r.roleId).filter((r: { roleId: string }) => r.roleId !== "" ) as T; } case "state_keys": { const stateKeys = await sqlGetStateKeys(id as string); if (!stateKeys || stateKeys.length === 0) return null; const sortedKeys = stateKeys.sort((a: { created_on: number }, b: { created_on: number }) => b.created_on - a.created_on ); return { seal_key: sortedKeys[0].seal_key, state_id: sortedKeys[0].state_id, cookie_hash: sortedKeys[0].cookie_hash, created_on: sortedKeys[0].created_on, } as T; } case "financial_balances": { const balances = await sqlGetFinancialBalancesByUser(id as string); if (!balances || balances.length === 0) return null; return balances[0] as T; } case "user_tokens": { const tokens = await sqlGetUserTokens(id as string); if (!tokens) return null; return { user_id: tokens.user_id, state_id: tokens.state_id, access_token: tokens.access_token, refresh_token: tokens.refresh_token, created_on: tokens.created_on, updated_on: tokens.updated_on, } as T; } case "user_pins": { const userPin = await sqlGetUserPin(id as string); if (!userPin) return null; return { user_id: userPin.user_id, pin: userPin.pin, created_on: userPin.created_on, modified_on: userPin.modified_on, } as T; } case "session_configs": { const owner = key[1] as string | undefined; if (!owner) return null; const sessionConfig = await sqlGetSessionConfig(owner); if (!sessionConfig) return null; return { device_id: sessionConfig.device_id, user_id: sessionConfig.user_id, other_data: sessionConfig.other_data, active_date: sessionConfig.active_date, quiz_configs: sessionConfig.quiz_configs, active_route: sessionConfig.active_route, page_data: sessionConfig.page_data, active_quiz_index: sessionConfig.active_quiz_index, active_question_index: sessionConfig.active_question_index, } as T; } case "quiz_configs": { const recordId = key[2] as string | undefined; if (!recordId) return null; const quizConfig = await sqlGetQuizConfig(recordId); if (!quizConfig) return null; return { quiz_config_id: quizConfig.quiz_config_id, quiz_name: quizConfig.quiz_name, owner_id: quizConfig.owner_id, quiz_mode: quizConfig.quiz_mode, active_date: quizConfig.active_date, tab_name: quizConfig.tab_name, tab_index: quizConfig.tab_index, list_index: quizConfig.list_index, questions: quizConfig.questions, results: quizConfig.results, } as T; } case "quiz_questions": { const recordId = key[2] as string | undefined; if (!recordId) return null; const question = await sqlGetQuizQuestion(recordId); if (!question) return null; return { id: question.id, quiz_id: question.quiz_id, owner_id: question.owner_id, question_index: question.question_index, language_1: question.language_1, language_2: question.language_2, text_1: question.text_1, audio_file_1: question.audio_file_1, text_2: question.text_2, audio_file_2: question.audio_file_2, order_num: question.order_num, background_file: question.background_file, background_image_data: question.background_image_data, video_file: question.video_file, video_file_data: question.video_file_data, } as T; } case "quiz_results": { const recordId = key[2] as string | undefined; if (!recordId) return null; const result = await sqlGetQuizResult(recordId); if (!result) return null; return { id: result.id, quiz_config_id: result.quiz_config_id, user_id: result.user_id, success: result.success, total_time: result.total_time, help_count: result.help_count, completed_on: result.completed_on, } as T; } case "brain_question_progress": { const recordId = key[2] as string | undefined; if (!recordId) return null; const progress = await sqlGetBrainProgressById(recordId); if (!progress) return null; return { id: progress.id, user_id: progress.user_id, quiz_id: progress.quiz_id, question_id: progress.question_id, current_mode: progress.current_mode, mode_index: progress.mode_index ?? 0, attempts: progress.attempts, first_try_successes: progress.first_try_successes, consecutive_first_try: progress.consecutive_first_try ?? 0, last_result: progress.last_result, next_due_at: progress.next_due_at, interval_days: progress.interval_days, ease_factor: progress.ease_factor, is_review_only: progress.is_review_only ?? false, created_on: progress.created_on, updated_on: progress.updated_on, } as T; } default: return null; } } } const defaultPersistDir = decodeURIComponent( new URL("../data", import.meta.url).pathname, ); const persistPath = Deno.env.get("KV_CACHE_PATH") ?? defaultPersistDir; export const kvStore = await KvCacheStore.initialize(persistPath);