Por qué las preferencias de locale necesitan un store persistente
I18nService.tUser() resuelve el locale de un usuario mediante una cadena de prioridad: primero preferencia de usuario, luego preferencia de guild y después el valor predeterminado configurado. Para que esa cadena funcione entre reinicios del bot, algo debe recordar esas preferencias. El store es ese algo. Es la única interfaz responsable de persistir y recuperar elecciones de locale de usuario y guild.
La razón por la que el store es un componente separado e intercambiable, en lugar de estar hardcodeado como una estructura de datos en memoria, es que las preferencias de locale tienen los mismos requisitos de persistencia que cualquier otro ajuste orientado al usuario. Un usuario que hoy elige portugués espera que su preferencia siga activa la próxima vez que el bot se reinicie. Ese requisito exige un backend de almacenamiento que sobreviva reinicios del proceso. El backend correcto depende por completo de lo que el bot ya use para otras necesidades de persistencia, y por eso el store es una interfaz y no una implementación.
Cómo funciona I18nStoreRegistry
I18nStoreRegistry es una clase estática que mantiene la referencia al store activo. El lifecycle de i18n siempre llama a I18nStoreRegistry.get() en el arranque para recuperar el store, haya o no uno personalizado registrado. Si no se configuró un store personalizado, el registro crea y devuelve automáticamente una instancia de MemoryStore. No se lanza ningún error y no hace falta configuración para uso básico.
La propiedad clave de este diseño es que el registro del store es independiente del objeto de configuración del plugin. Llamas a I18nStoreRegistry.set(yourStore) en cualquier punto antes de que se ejecute el lifecycle de i18n. Puede ser el archivo principal de bootstrap, un hook de lifecycle personalizado o cualquier parte de la secuencia de arranque del bot. El registro mantiene la referencia hasta que el bot se apaga o hasta que se llama a reset().
Registrar un store personalizado
import { I18nStoreRegistry } from '@spraxium/i18n';
import { IntentPreset, SpraxiumFactory } from '@spraxium/core';
import { DrizzleLocaleStore } from './stores/drizzle-locale.store';
import { db } from './database/connection';
async function main(): Promise<void> {
I18nStoreRegistry.set(new DrizzleLocaleStore(db));
const app = await SpraxiumFactory.create({
token: process.env.DISCORD_TOKEN!,
});
app.intents(IntentPreset.Standard);
await app.listen();
}
main();El store se define al inicio de main antes de llamar a SpraxiumFactory.create(), así que está disponible antes de que corra el lifecycle de i18n. Registrarlo en el archivo principal de bootstrap mantiene la dependencia explícita y visible de un vistazo.
Implementar un store personalizado
Cualquier clase que implemente I18nStore es un store válido. La interfaz exige cinco métodos. Los ejemplos de abajo muestran implementaciones equivalentes usando las librerías de base de datos más comunes para que puedas adaptar el patrón a cualquier ORM que ya use tu proyecto.
import type { I18nStore } from '@spraxium/i18n';
import { eq } from 'drizzle-orm';
import { localePreferences, guildLocalePreferences } from '../db/schema';
export class DrizzleLocaleStore implements I18nStore {
constructor(private readonly db: DrizzleDatabase) {}
async init(): Promise<void> {
// Run migrations, create tables, or perform connection checks here.
// Called once at boot by I18nLifecycle, not on every translation request.
}
async getUser(userId: string): Promise<string | undefined> {
const row = await this.db
.select()
.from(localePreferences)
.where(eq(localePreferences.userId, userId))
.limit(1);
return row[0]?.locale ?? undefined;
}
async setUser(userId: string, locale: string): Promise<void> {
await this.db
.insert(localePreferences)
.values({ userId, locale })
.onConflictDoUpdate({ target: localePreferences.userId, set: { locale } });
}
async getGuild(guildId: string): Promise<string | undefined> {
const row = await this.db
.select()
.from(guildLocalePreferences)
.where(eq(guildLocalePreferences.guildId, guildId))
.limit(1);
return row[0]?.locale ?? undefined;
}
async setGuild(guildId: string, locale: string): Promise<void> {
await this.db
.insert(guildLocalePreferences)
.values({ guildId, locale })
.onConflictDoUpdate({ target: guildLocalePreferences.guildId, set: { locale } });
}
}| Método | Cuándo se llama | Contrato |
|---|---|---|
| init() | Una vez en el arranque, por I18nLifecycle | Setup y migraciones. Puede fallar con throw. Un error aquí aborta el arranque. |
| getUser(userId) | En cada llamada a tUser() o resolveLocale() | Devuelve string de locale guardado o undefined. Puede ser async. |
| setUser(userId, locale) | Cuando el usuario llama a un comando de cambio de locale | Persiste la preferencia. Debe sobrescribir valores existentes. |
| getGuild(guildId) | En cada llamada a tUser() o resolveLocale() sin preferencia de usuario | Devuelve string de locale guardado o undefined. Puede ser async. |
| setGuild(guildId, locale) | Cuando un admin ejecuta un comando de cambio de locale | Persiste la preferencia de guild. Debe sobrescribir valores existentes. |
Usar MemoryStore integrado para desarrollo
MemoryStore es el fallback predeterminado. Guarda preferencias en dos instancias de Map que se crean de cero cuando inicia el proceso y se pierden cuando se detiene. No requiere setup, base de datos, ni variables de entorno. Para desarrollo local o para bots donde las preferencias de locale no son una funcionalidad, MemoryStore es la opción correcta: el sistema funciona bien, las preferencias simplemente no sobreviven a los reinicios.
Nunca necesitas registrar MemoryStore explícitamente. Se usa automáticamente cuando no hay un store personalizado registrado. Si quieres ser explícito por documentación o pruebas, igual puedes llamar a I18nStoreRegistry.set(new MemoryStore()) en cualquier punto.
Un comando de cambio de locale que escribe en el store
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { I18nService } from '@spraxium/i18n';
import type { ChatInputCommandInteraction } from 'discord.js';
import { LocaleCommand } from '../commands/locale.command';
@SlashCommandHandler(LocaleCommand)
export class LocaleHandler {
constructor(private readonly i18n: I18nService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const locale = interaction.options.getString('locale', true);
const userId = interaction.user.id;
await this.i18n.setUserLocale(userId, locale);
const confirmation = await this.i18n.tUser(
userId,
'commands.locale.changed',
{ locale },
);
await interaction.reply({ content: confirmation, ephemeral: true });
}
}I18nService.setUserLocale() delega directamente en store.setUser(). La siguiente llamada a tUser() de este usuario resolverá al nuevo locale. No hay caché que invalidar porque la cadena de resolución de prioridad siempre lee del store en cada llamada.
Resetear el store en tests
La naturaleza estática de I18nStoreRegistry significa que el estado puede filtrarse entre casos de prueba. Usa reset() en hooks de teardown para mantener los tests aislados:
import { I18nStoreRegistry, MemoryStore } from '@spraxium/i18n';
beforeEach(() => {
I18nStoreRegistry.set(new MemoryStore());
});
afterEach(() => {
I18nStoreRegistry.reset();
});