Translations are stored in three tables: Bundle, Message, and Variant.

The data model is relational (SQL) and heavily inspired by Unicode MessageFormat 2.

Bundle (greeting)
├── Message (en)
│   └── Variant ("Hello {name}!")
├── Message (de)
│   └── Variant ("Hallo {name}!")
└── Message (fr)
    └── Variant ("Bonjour {name}!")

Bundle

A bundle groups translations by key. One bundle = one translatable unit across all locales.

type Bundle = {
  id: string;           // e.g., "greeting", "error_404"
  declarations: Declaration[];
}

The id is your translation key — what you reference in code. The id is assumed to be stable; changing it would break all references. Declarations define variables available to all messages in the bundle.

Message

A message is a locale-specific translation. One message per locale per bundle.

type Message = {
  id: string;           // auto-generated UUID
  bundleId: string;     // references Bundle.id
  locale: string;       // e.g., "en", "de", "fr"
  selectors: VariableReference[];
}

Selectors are used for conditional matching (plurals, gender, etc.). If your message has no conditions, selectors is empty.

Variant

A variant is the actual text pattern. Most messages have one variant, but pluralization requires multiple.

type Variant = {
  id: string;           // auto-generated UUID
  messageId: string;    // references Message.id
  matches: Match[];     // conditions for this variant
  pattern: Pattern;     // the text content
}

Simple example

A greeting with no pluralization:

Bundle: "greeting"
└── Message: locale="en"
    └── Variant: pattern="Hello {name}!"
└── Message: locale="de"
    └── Variant: pattern="Hallo {name}!"

Pluralization example

A message with plural forms needs multiple variants:

Bundle: "items_count"
  declarations: [{ type: "input-variable", name: "count" }]
└── Message: locale="en", selectors=["count"]
    └── Variant: matches=[{key: "count", value: "one"}], pattern="One item"
    └── Variant: matches=[{key: "count", value: "other"}], pattern="{count} items"
└── Message: locale="de", selectors=["count"]
    └── Variant: matches=[{key: "count", value: "one"}], pattern="Ein Artikel"
    └── Variant: matches=[{key: "count", value: "other"}], pattern="{count} Artikel"

Pattern syntax

Patterns contain text and expressions (variables):

  • Text — Plain strings: "Hello world"
  • Expression — Variables wrapped in braces: "Hello {name}!"

Expressions can have annotations for formatting:

{count}              // plain variable
{count: number}      // format as number
{date: date}         // format as date
{count: plural}      // pluralization

Querying the data model

Use Kysely to query messages:

// Get all messages for a bundle
const messages = await project.db
  .selectFrom("message")
  .where("bundleId", "=", "greeting")
  .selectAll()
  .execute();

// Get all bundles with their messages
const bundles = await project.db
  .selectFrom("bundle")
  .leftJoin("message", "message.bundleId", "bundle.id")
  .selectAll()
  .execute();

// Find missing translations
const missing = await project.db
  .selectFrom("bundle")
  .where((eb) =>
    eb.not(
      eb.exists(
        eb.selectFrom("message")
          .where("message.bundleId", "=", eb.ref("bundle.id"))
          .where("message.locale", "=", "de")
      )
    )
  )
  .selectAll()
  .execute();

Next steps