integratedhub/modules/KvCacheStore.ts
2026-01-29 11:27:15 -06:00

1012 lines
30 KiB
TypeScript

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<T = unknown>(key: Deno.KvKey): Promise<{ value: T | null }>;
set<T = unknown>(key: Deno.KvKey, value: T): Promise<Deno.KvCommitResult>;
delete(key: Deno.KvKey): Promise<void>;
list<T = unknown>(
options?: { prefix?: Deno.KvKey },
): AsyncIterable<{ key: Deno.KvKey; value: T }>;
close(): Promise<void> | 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<KvCacheStore> {
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<T = unknown>(key: Deno.KvKey): Promise<T | null> {
try {
const result = await this.memKv.get<T>(key);
if (result.value !== null) {
return result.value;
}
const sqlResult = await this.fetchFromSql<T>(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<T = unknown>(key: Deno.KvKey, value: T): Promise<void> {
try {
const existing = await this.get<T>(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<void> {
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<T = unknown>(
options?: { prefix?: Deno.KvKey },
): AsyncIterable<{ key: Deno.KvKey; value: T }> {
const yieldedKeys = new Set<string>();
for await (const entry of this.memKv.list<T>(options ?? {})) {
const keyStr = JSON.stringify(entry.key);
if (!yieldedKeys.has(keyStr)) {
yieldedKeys.add(keyStr);
yield entry;
}
}
for await (const entry of this.fileKv.list<T>(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<T>(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<T = unknown>(
namespace: string,
): Promise<Array<{ key: Deno.KvKey; value: T }>> {
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<string, string[]>();
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<void> {
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<Array<string>>;
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<T = unknown>(key: Deno.KvKey): Promise<T | null> {
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 | null }) => 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 | null }) => r.roleId).filter((roleId: string | null) =>
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);