Spraxium logoSpraxium

Store API

The store is the persistence layer for user and guild locale preferences. Learn how I18nStoreRegistry decouples store registration from plugin configuration.

Why locale preferences need a persistent store

I18nService.tUser() resolves the locale for a user through a priority chain: user preference, then guild preference, then the configured default. For that chain to work across bot restarts, something must remember those preferences. The store is that something. It is the single interface responsible for persisting and retrieving user and guild locale choices.

The reason the store is a separate, swappable component, rather than being hardcoded as an in-memory data structure, is that locale preferences have the same persistence requirements as any other user-facing setting. A user who chooses Portuguese today expects their preference to still be active the next time the bot restarts. That requirement demands a storage backend that survives process restarts. The right backend depends entirely on what the bot already uses for other persistence needs, which is why the store is an interface rather than an implementation.

How I18nStoreRegistry works

I18nStoreRegistry is a static class that holds the active store reference. The i18n lifecycle always calls I18nStoreRegistry.get() at boot to retrieve the store whether or not a custom one was registered. If no custom store has been set, the registry creates and returns a MemoryStore instance automatically. No error is thrown and no configuration is required for basic usage.

The key property of this design is that store registration is independent of the plugin config object. You call I18nStoreRegistry.set(yourStore) at any point before the i18n lifecycle runs. This can be the main bootstrap file, a custom lifecycle hook, or anywhere else in your bot's startup sequence. The registry holds the reference until the bot shuts down or until reset() is called.

Registering a custom store

src/main.ts
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();

The store is set at the top of main before SpraxiumFactory.create() is called, which means it is available before the i18n lifecycle runs. Registering it in the main bootstrap file keeps the dependency explicit and visible at a glance.

Implementing a custom store

Any class that implements I18nStore is a valid store. The interface requires five methods. The examples below show equivalent implementations using the most common database libraries so you can adapt the pattern to whatever ORM your project already uses.

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 } });
  }
}
MethodWhen calledContract
init()Once at boot, by I18nLifecycleSetup and migrations. May throw. A failure here aborts boot.
getUser(userId)On every tUser() or resolveLocale() callReturn stored locale string or undefined. May be async.
setUser(userId, locale)When user calls a locale-change commandPersist the preference. Must overwrite existing values.
getGuild(guildId)On every tUser() or resolveLocale() call missing user prefReturn stored locale string or undefined. May be async.
setGuild(guildId, locale)When an admin runs a locale-change commandPersist the guild preference. Must overwrite existing values.

Using the built-in MemoryStore for development

MemoryStore is the default fallback. It stores preferences in two Map instances that are created fresh when the process starts and lost when it stops. It requires no setup, no database, and no environment variables. For local development or for bots where locale preferences are not a feature, MemoryStore is the right choice: the system works correctly, preferences just do not survive restarts.

You never need to explicitly register MemoryStore. It is used automatically when no custom store is registered. If you want to be explicit for documentation or testing purposes, you can still call I18nStoreRegistry.set(new MemoryStore()) at any point.

A locale-change command that writes to the store

locale.handler.ts
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, flags: 'Ephemeral' });
  }
}

I18nService.setUserLocale() delegates directly to store.setUser(). The next call to tUser() from this user will resolve to the new locale. There is no cache to invalidate because the priority resolution chain always reads from the store on every call.

Resetting the store in tests

The static nature of I18nStoreRegistry means state can leak between test cases. Use reset() in teardown hooks to keep tests isolated:

import { I18nStoreRegistry, MemoryStore } from '@spraxium/i18n';

beforeEach(() => {
  I18nStoreRegistry.set(new MemoryStore());
});

afterEach(() => {
  I18nStoreRegistry.reset();
});