Skip to content

Translations (i18n) ​

Register a translator with moost once, and @atscript/moost-db answers each request in its language (since 0.1.170): /meta and /meta/form/:name serve translated labels, messages and action texts, and error bodies come back translated. On the browser, the db-client asks for a locale and translates its own preflight errors with the templates /meta serves.

typescript
import { setTranslator } from "moost";
import { MoostHttp } from "@moostjs/event-http";

setTranslator({
  en: {},
  de: {
    Title: "Titel",
    "Title is too short": "Der Titel ist zu kurz",
    Archive: "Archivieren",
    MIN_LENGTH: "Mindestens {min} Zeichen",
    UNIQUE: "{fields} ist bereits vergeben",
  },
});

const http = new MoostHttp(); // negotiates Accept-Language against en, de
typescript
// browser
import { Client } from "@atscript/db-client";

const todos = new Client<typeof Todo>("/todos", { locale: () => currentLocale.value });

setTranslator takes a map or any i18n library (i18next, FormatJS, …) — see the moost i18n guide for the function form, locales and how a request's locale is picked. The rest of this page covers what moost-db does with it.

Message ids ​

Every text is looked up by an id, in the source language your models are written in:

TextIdExample
An annotation text (@meta.label, @meta.description, a constraint's message, …)the text itself"Title is too short"
An action's label, description, promptTextthe text itself"Archive $N items?"
A reason a disabled predicate returnsthe text itself"Already shipped"
A validator message without a text of its ownits code, a template with {param}sMIN_LENGTH → "Mindestens {min} Zeichen"
A db validator messageits codeNAV_NULL
A database error and its entriesthe error's code / each entry's codeCONFLICT, UNIQUE, FK_MISSING
Any other error messagethe text itself"Unknown field \"x\""

The validator's codes and templates are listed in atscript's Validation Reference; the texts in .as files and their translation on the client in atscript's i18n guide. moost-db registers these template catalogs (VALIDATOR_MESSAGES, DB_VALIDATOR_MESSAGES) and its own (MOOST_DB_MESSAGES: NOT_FOUND — "Not found", the 404 body, since 0.1.173) with moost when it is imported.

What /meta serves ​

With a translator registered, GET /meta in German differs from the source in three places:

  • type and decorations: every annotation text is translated, at any depth.
  • actions[]: label, description and promptText (both forms of a [singular, plural] pair) are translated. $1 / $N stay as your translation writes them; the UI fills them in.
  • type.$i18n: a flat { [id]: text } map of what the client translates itself — every message template registered with moost's registerMessages (the validator's, moost-db's, your app's and other packages' catalogs such as UI strings or authentication messages; only those whose translation differs from English, {param}s kept) and the translated string literals of expression annotations. Absent when there is nothing to serve.

Registered templates are public

Every client that asks for a translated /meta or form receives the translated templates of every catalog registered in the process. Don't register texts you would not show to any user.

json
{
  "type": {
    "type": { "kind": "object", "props": { "title": { "metadata": { "meta.label": "Titel" } } } },
    "$i18n": { "MIN_LENGTH": "Mindestens {min} Zeichen" }
  },
  "actions": [{ "name": "archive", "label": "Archivieren" }]
}

GET /meta/form/:name is translated the same way and carries its own $i18n.

The translation is the last step, after applyMetaOverlay and the other per-request hooks: an overlay works on source texts.

Disabled reasons ​

A reason a disabled predicate returns is a message id too. moost-db translates it everywhere it reaches a client, so the row menu and a refused run read alike: $disabledReasons on rows ($actions=true), disabledReasons of GET /meta/actions/:id, skipped[].reason of summary() (a query-target run, or onDisabledRows: 'skip'), and reason / reasons / message of the 409 ActionDisabledError. Return the source text from the predicate and add it to your translations; keep the predicate pure — its source is also served as actions[].disabled.

Which locale ​

moost picks the locale per request from Accept-Language, matched against the translator's locales. A request without the header, or with a language you don't serve, gets the first locale — put the source language first ({ en: {}, de: {…} }) and it receives the untranslated /meta, byte for byte.

Without a translator nothing changes: the same bytes as before, no extra headers.

Caching per language ​

Each language is a different response with its own ETag, and the response carries Vary: Accept-Language next to Authorization, Cookie (see Caching and revalidation). A browser revalidating German /meta gets a 304 while the bytes are unchanged; switching to English gets a 200 with the English body. Each language's payload is built once and served from its serialized JSON.

Wording that differs per tenant, brand or user group uses the translator's scope (moost ≥ 0.7.4, moost-db ≥ 0.1.172): the payload is then built per language and scope, each with its own ETag. If the scope comes from a request header other than Authorization or Cookie, add that header to Vary with metaHttpCaching().

Opting out — metaTranslation() ​

Override metaTranslation() to return false and the controller serves /meta and its forms in the source texts. Use it when the wording depends on something other than the locale and the scope: the translated payload is cached per language and scope, so a translator must give the same text for the same id, locale and scope.

typescript
@TableController(todosTable)
export class TodoController extends AsDbController<typeof Todo> {
  protected override metaTranslation(): boolean {
    return false;
  }
}

Error bodies are translated by moost either way.

Error bodies ​

MoostHttp translates every error body before it is sent (moost i18n guide). For moost-db's envelopes { message, statusCode, code?, errors } that means:

  • each entry of errors by its code (with its params), else by its message text;
  • the top message by the body's code (a DbError's CONFLICT, FK_VIOLATION, …, or NOT_FOUND of every 404), else as the first entry's translation when it repeats it ("title: Der Titel ist zu kurz"), else by its text.

The problems validateRows returns carry the same code and params, so a dry run thrown as a ValidatorError is translated like a write's errors.

json
{
  "message": "Konflikt",
  "statusCode": 409,
  "code": "CONFLICT",
  "errors": [
    {
      "path": "title",
      "message": "title ist bereits vergeben",
      "code": "UNIQUE",
      "params": { "index": "title_idx", "fields": ["title"] }
    }
  ]
}

code and params never change with the locale — branch on them, not on message.

db validator codes ​

The write validator's own checks report these codes (since 0.1.170), exported with their English templates as DB_VALIDATOR_MESSAGES from @atscript/db:

CodeParamsEnglish template
GEO_POINT_SHAPE—Expected a [lng, lat] tuple of two finite numbers (GeoJSON order)
GEO_LONGITUDE_RANGEvalueLongitude {value} out of range [-180, 180] (geo points are [lng, lat])
GEO_LATITUDE_RANGEvalueLatitude {value} out of range [-90, 90] (geo points are [lng, lat])
FIELD_OP_ON_DERIVED—Field operations ($inc/$dec/$mul) are not allowed on a @db.column.derived field — …
FIELD_OP_CONTEXT—Field operations ($inc/$dec/$mul) are not supported inside @db.json fields, array items, or nested objects …
FIELD_OP_NOT_NUMERIC—Field operations ($inc/$dec/$mul) can only be applied to numeric fields
FIELD_OP_NOT_FINITEoperand (text)Field operation operand must be a finite number, got
NAV_NULLfieldCannot process null navigation property '{field}'
NAV_PATCH_PLAIN_VALUErelation, fieldCannot patch {relation} relation '{field}' with a plain value — use patch operators (…)
NAV_PATCH_NOT_ARRAYfieldCannot use patch operators on non-array relation '{field}'
PATCH_UNKNOWN_OPERATORoperators (list)Unknown patch operator(s): {operators}. Allowed: $replace, $insert, $upsert, $update, $remove
PATCH_OPERAND_NOT_ARRAYop{op} must be an array
PATCH_ITEM_NOT_OBJECTop, index{op}[{index}]: expected object
PATCH_KEY_REQUIREDop, index, field{op}[{index}]: key field '{field}' is required
PATCH_FIELD_REQUIREDop, index, field{op}[{index}]: field '{field}' is required (replace strategy)

On the client — locale ​

ClientOptions.locale (a string, or a function read per request) is sent as Accept-Language on every request, unless your headers already carry one:

typescript
import { Client, ClientError } from "@atscript/db-client";

const todos = new Client<typeof Todo>("/todos", { locale: () => i18n.locale });

try {
  await todos.insert({ title: "ab" });
} catch (e) {
  if (e instanceof ClientError) {
    e.errors; // [{ path: "title", message: "Der Titel ist zu kurz", code: "MIN_LENGTH", params: { min: 3, actual: 2 } }]
  }
}
  • Preflight errors are translated with the served type.$i18n (ClientValidationError entries keep code and params). createClientValidator(meta) does the same; pass messages to a ClientValidator you build yourself.
  • The cached /meta, the validator and the action forms belong to one locale. After a switch, the next call loads them again. The meta store keeps one entry per locale, so switching back costs a 304.
  • Objects you already hold don't change. Re-read them after a switch — in a UI, remount the views that show them (e.g. key the router view by the locale).
  • One client per locale on a server rendering for many users (SSR): create clients per request — or per locale — rather than sharing one with a locale getter across concurrent requests.

DOs and DON'Ts ​

  • DO load every language before the server listens, or call setTranslator again once they are loaded — the translated /meta is cached per language.
  • DO branch on code / params, never on message.
  • DON'T expect labels you didn't write to be translated: a column label derived from the field name has no id — give it a @meta.label.
  • Plural forms in the validator templates are rendered with placeholders, so an ICU plural served in $i18n falls back to its other form on the client.

See also ​

Released under the MIT License.