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.
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// 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:
| Text | Id | Example |
|---|---|---|
An annotation text (@meta.label, @meta.description, a constraint's message, …) | the text itself | "Title is too short" |
An action's label, description, promptText | the text itself | "Archive $N items?" |
A reason a disabled predicate returns | the text itself | "Already shipped" |
| A validator message without a text of its own | its code, a template with {param}s | MIN_LENGTH → "Mindestens {min} Zeichen" |
| A db validator message | its code | NAV_NULL |
| A database error and its entries | the error's code / each entry's code | CONFLICT, UNIQUE, FK_MISSING |
| Any other error message | the 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:
typeanddecorations: every annotation text is translated, at any depth.actions[]:label,descriptionandpromptText(both forms of a[singular, plural]pair) are translated.$1/$Nstay 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'sregisterMessages(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.
{
"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.
@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
errorsby itscode(with itsparams), else by its message text; - the top
messageby the body'scode(aDbError'sCONFLICT,FK_VIOLATION, …, orNOT_FOUNDof 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.
{
"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:
| Code | Params | English template |
|---|---|---|
GEO_POINT_SHAPE | — | Expected a [lng, lat] tuple of two finite numbers (GeoJSON order) |
GEO_LONGITUDE_RANGE | value | Longitude {value} out of range [-180, 180] (geo points are [lng, lat]) |
GEO_LATITUDE_RANGE | value | Latitude {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_FINITE | operand (text) | Field operation operand must be a finite number, got |
NAV_NULL | field | Cannot process null navigation property '{field}' |
NAV_PATCH_PLAIN_VALUE | relation, field | Cannot patch {relation} relation '{field}' with a plain value — use patch operators (…) |
NAV_PATCH_NOT_ARRAY | field | Cannot use patch operators on non-array relation '{field}' |
PATCH_UNKNOWN_OPERATOR | operators (list) | Unknown patch operator(s): {operators}. Allowed: $replace, $insert, $upsert, $update, $remove |
PATCH_OPERAND_NOT_ARRAY | op | {op} must be an array |
PATCH_ITEM_NOT_OBJECT | op, index | {op}[{index}]: expected object |
PATCH_KEY_REQUIRED | op, index, field | {op}[{index}]: key field '{field}' is required |
PATCH_FIELD_REQUIRED | op, 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:
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(ClientValidationErrorentries keepcodeandparams).createClientValidator(meta)does the same; passmessagesto aClientValidatoryou 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 a304. - 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
setTranslatoragain once they are loaded — the translated/metais cached per language. - DO branch on
code/params, never onmessage. - 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
$i18nfalls back to itsotherform on the client.
See also
- moost i18n guide —
setTranslator, locales,useTranslate, error-body translation - atscript i18n — message ids in
.asfiles,$i18n, translating on the client - Error responses — the envelope shape
- HTTP Client — all client options