Documentation
¶
Overview ¶
Package i18n adds entity-content translations to any r3.CRUD[T, ID]. The decorator wraps the repo and transparently satisfies it: on reads (Get, List) it overlays translated values onto translatable fields for the context locale (r3.WithLocale / r3.GetLocale); on writes (Update, Patch) it detects source-text changes and marks the affected translations stale for a worker to redo.
"Everything is a R3po" - translations are themselves r3 entities, stored via any r3.CRUD[Translation, string]. Entities can live in PostgreSQL and translations in SQLite, MongoDB, or anywhere else.
Key properties:
- Locale from context: r3.WithLocale(ctx, "ru") in middleware is the only per-request wiring; every read localizes itself.
- Batched overlay: List fetches a page's translations in ONE store query (entity_id IN (...)), never N+1. A declared preloaded relation adds one more query for the whole page, not one per child.
- Preloaded children: by default only the decorated entity is translated, so a preloaded child keeps its source language. Declare the relations that carry translatable text with WithPreloads (see below).
- Fallback to source: a field with no (or empty) translation keeps its original text.
- Staleness via SourceHash: each Translation stores a hash of the source text it translated. When Update/Patch changes a source field, matching translations are patched Stale=true (best-effort; mutations never fail over it). Stale translations are still served by default (an outdated translation usually beats the wrong language) - pass WithoutStale to hide them.
- Delete: leaves translations in place by default (safe with soft-delete/restore); opt into cleanup with WithDeleteOnEntityDelete.
Read-modify-write warning ¶
Wrap READ-facing repositories only. If an admin/editor flow Gets an entity through this decorator with a locale set and saves it back, the translated values overwrite the source text. Keep mutation paths on the unwrapped repository (or ensure their contexts carry no locale).
WithPreloads widens this from one row to a row plus everything preloaded onto it: saving a parent read under a locale can persist translated child text through whatever association sync the driver performs. With preloads declared, "read-facing only" stops being cautious advice and becomes load-bearing.
Preloaded children ¶
Preloads are resolved by the driver, below every decorator, so wrapping the child's own repo in i18n does nothing for a parent's preload - that repo is never consulted. Declare the relation on the parent instead:
repo := i18n.WithTranslations[Location, int64](
inner, translationStore,
i18n.WithIDFunc[Location, int64](func(l Location) int64 { return l.ID }),
i18n.WithFields[Location, int64]("title", "description"),
i18n.WithPreloads[Location, int64](
i18n.TranslatedRelation("City", func(c City) int64 { return c.ID }, "name"),
i18n.TranslatedRelation("Events", func(e Event) int64 { return e.ID }, "name"),
),
)
loc, _ := repo.Get(r3.WithLocale(ctx, "ru"), 42) // loc.City.Name is Russian too
The relation field may be C, *C, []C or []*C, and is named by its Go field name, the way r3.Preloads names it. Children nest one level: a child's own children are not reached.
This is a read overlay only. A parent's Update/Patch never marks a child's translations stale; that belongs to whatever repo owns the child.
Usage:
repo := i18n.WithTranslations[Location, int64](
inner,
translationStore, // any r3.CRUD[i18n.Translation, string]
i18n.WithIDFunc[Location, int64](func(l Location) int64 { return l.ID }),
i18n.WithFields[Location, int64]("title", "description"),
)
// Serving a Russian-speaking user:
ctx = r3.WithLocale(ctx, "ru")
loc, _ := repo.Get(ctx, 42) // Title/Description come back in Russian when translated
Translation writers (workers, admin editors) use the same store directly: compute SourceHash with Hash(sourceText), then Upsert. The query builders (QueryFor, QueryForEntity, QueryStale) keep the worker's queue logic in one place.
Index ¶
- Variables
- func Hash(sourceText string) string
- func QueryFor(entityType, entityID, lang string, fields ...string) r3.Query
- func QueryForBatch(entityType, lang string, entityIDs []string) r3.Query
- func QueryForEntity(entityType, entityID string) r3.Query
- func QueryStale(entityType string) r3.Query
- type CRUD
- func (c *CRUD[T, ID]) Aggregate(ctx context.Context, qarg ...r3.Query) ([]r3.AggregateRow, error)
- func (c *CRUD[T, ID]) AggregateThroughRelation(ctx context.Context, relation string, qarg ...r3.Query) ([]r3.AggregateRow, error)
- func (c *CRUD[T, ID]) Count(ctx context.Context, qarg ...r3.Query) (int64, error)
- func (c *CRUD[T, ID]) Create(ctx context.Context, entity T) (T, error)
- func (c *CRUD[T, ID]) Delete(ctx context.Context, id ID) error
- func (c *CRUD[T, ID]) Get(ctx context.Context, id ID, qarg ...r3.Query) (T, error)
- func (c *CRUD[T, ID]) Inner() r3.CRUD[T, ID]
- func (c *CRUD[T, ID]) List(ctx context.Context, qarg ...r3.Query) ([]T, int64, error)
- func (c *CRUD[T, ID]) Patch(ctx context.Context, entity T, fields r3.Fields) (T, error)
- func (c *CRUD[T, ID]) PatchWhere(ctx context.Context, filters r3.Filters, entity T, fields r3.Fields) (int64, error)
- func (c *CRUD[T, ID]) Rewrap(inner r3.CRUD[T, ID]) r3.CRUD[T, ID]
- func (c *CRUD[T, ID]) Translations() r3.CRUD[Translation, string]
- func (c *CRUD[T, ID]) Unwrap() r3.CRUD[T, ID]
- func (c *CRUD[T, ID]) Update(ctx context.Context, entity T) (T, error)
- func (c *CRUD[T, ID]) Upsert(ctx context.Context, entity T, opts ...r3.UpsertOption) (T, error)
- type IDFunc
- type Option
- func WithDeleteOnEntityDelete[T any, ID comparable]() Option[T, ID]
- func WithEntityType[T any, ID comparable](name string) Option[T, ID]
- func WithErrorHandler[T any, ID comparable](fn func(error)) Option[T, ID]
- func WithFields[T any, ID comparable](names ...string) Option[T, ID]
- func WithIDFunc[T any, ID comparable](fn IDFunc[T, ID]) Option[T, ID]
- func WithPreloads[T any, ID comparable](rels ...Translated) Option[T, ID]
- func WithoutOverlay[T any, ID comparable]() Option[T, ID]
- func WithoutStale[T any, ID comparable]() Option[T, ID]
- type Options
- type Source
- type Translated
- type Translation
Constants ¶
This section is empty.
Variables ¶
var ErrNotTranslatable = errors.New("i18n: field is not translatable")
ErrNotTranslatable reports an invalid WithFields configuration: a requested field is missing from the entity struct or not a string. Raised as a panic (wrapping this error) at WithTranslations time - a wiring-time programming error, not a request-time one.
Functions ¶
func Hash ¶
Hash returns the canonical hash of a source text, used for SourceHash. Translation writers must use it so the decorator's staleness detection agrees with them.
func QueryFor ¶
QueryFor matches the translations of one entity in one language; pass fields to narrow to specific translated fields (none = all fields).
func QueryForBatch ¶
QueryForBatch matches the translations of many entities in one language - the single query behind List overlays (never N+1).
func QueryForEntity ¶
QueryForEntity matches every translation of one entity - all languages, all fields. Used by the decorator for staleness marking and delete cleanup, and by admin UIs listing an entity's translations.
func QueryStale ¶
QueryStale matches stale translations of one entity type (any language) - a worker's re-translation queue. Entities with NO translation yet are invisible here; finding those needs an anti-join ("has no translation") that r3's relationship filters can't express yet, so applications write that query natively for now.
Types ¶
type CRUD ¶
type CRUD[T any, ID comparable] struct { // contains filtered or unexported fields }
CRUD localizes reads (Get/List overlay translated values for the r3.GetLocale locale) and tracks staleness on writes (Update/Patch mark translations of changed source fields stale). The translation store is any r3.CRUD[Translation, string]. Wrap read-facing repos only - see the package doc's read-modify-write warning.
func WithTranslations ¶
func WithTranslations[T any, ID comparable]( inner r3.CRUD[T, ID], store r3.CRUD[Translation, string], optFns ...Option[T, ID], ) *CRUD[T, ID]
WithTranslations wraps inner with locale-aware reads and staleness tracking. IDFunc and Fields are required; it panics on misconfiguration (unknown or non-string field, missing IDFunc) so wiring errors fail at startup, not at request time.
func (*CRUD[T, ID]) Aggregate ¶ added in v0.0.3
Aggregate passes through: aggregated rows carry counts/sums, not translatable text, so nothing is localized.
func (*CRUD[T, ID]) AggregateThroughRelation ¶ added in v0.0.5
func (c *CRUD[T, ID]) AggregateThroughRelation( ctx context.Context, relation string, qarg ...r3.Query, ) ([]r3.AggregateRow, error)
AggregateThroughRelation passes through: aggregated values are counts/sums, not translatable text.
func (*CRUD[T, ID]) Delete ¶
Delete removes an entity; with WithDeleteOnEntityDelete it also removes the entity's translations (best-effort).
func (*CRUD[T, ID]) List ¶
List retrieves entities and overlays translations for the context locale, fetching the whole page's translations in a single store query.
func (*CRUD[T, ID]) Patch ¶
Patch partially updates an entity, then marks translations whose source text changed as stale (best-effort).
func (*CRUD[T, ID]) PatchWhere ¶ added in v0.0.4
func (c *CRUD[T, ID]) PatchWhere( ctx context.Context, filters r3.Filters, entity T, fields r3.Fields, ) (int64, error)
PatchWhere forwards a bulk conditional update to the inner CRUD. It does NOT mark translations stale: a bulk update has no per-row result to hash, and this capability targets non-translatable status-style columns. If a bulk update can touch a translated source field, mark the affected translations stale explicitly via Translations().
func (*CRUD[T, ID]) Rewrap ¶
Rewrap rebuilds this decorator around inner, re-applying the i18n layer on top of a transaction-bound CRUD.
func (*CRUD[T, ID]) Translations ¶
func (c *CRUD[T, ID]) Translations() r3.CRUD[Translation, string]
Translations returns the translation store for querying or writing rows directly (workers, admin editors) - use with the Query* builders and Upsert.
func (*CRUD[T, ID]) Unwrap ¶
Unwrap returns the wrapped CRUD so decorator-chain walks (capability detection, transaction propagation) can reach the backend.
func (*CRUD[T, ID]) Update ¶
Update modifies an entity, then marks translations whose source text changed as stale (best-effort; the update itself never fails because of it).
type IDFunc ¶
type IDFunc[T any, ID comparable] func(entity T) ID
IDFunc extracts the primary key from an entity. The decorator stringifies it (fmt.Sprint) to match Translation.EntityID.
type Option ¶
type Option[T any, ID comparable] func(*Options[T, ID])
Option is a functional option for configuring the decorator.
func WithDeleteOnEntityDelete ¶
func WithDeleteOnEntityDelete[T any, ID comparable]() Option[T, ID]
WithDeleteOnEntityDelete removes an entity's translations when the entity is deleted. Leave off for soft-deleted entities that may be restored.
func WithEntityType ¶
func WithEntityType[T any, ID comparable](name string) Option[T, ID]
WithEntityType sets an explicit entity type name.
func WithErrorHandler ¶
func WithErrorHandler[T any, ID comparable](fn func(error)) Option[T, ID]
WithErrorHandler sets a handler for best-effort side-operation failures, overriding the default slog logging.
func WithFields ¶
func WithFields[T any, ID comparable](names ...string) Option[T, ID]
WithFields declares which fields of T are translatable, by storage name (db-tag name or snake_cased Go name). Required.
func WithIDFunc ¶
func WithIDFunc[T any, ID comparable](fn IDFunc[T, ID]) Option[T, ID]
WithIDFunc sets the function that extracts the primary key from an entity. Required.
func WithPreloads ¶ added in v0.7.0
func WithPreloads[T any, ID comparable](rels ...Translated) Option[T, ID]
WithPreloads declares preloaded relations that carry translatable text, built with TranslatedRelation. Without it, a preloaded child keeps its source language while its parent is translated.
Each declared relation costs one extra store query per read, for the whole page of parents rather than per child.
func WithoutOverlay ¶
func WithoutOverlay[T any, ID comparable]() Option[T, ID]
WithoutOverlay makes this a staleness-tracking-only decorator: reads pass through untranslated, writes still invalidate translations. For admin/editor repositories.
func WithoutStale ¶
func WithoutStale[T any, ID comparable]() Option[T, ID]
WithoutStale hides stale translations on reads (fall back to source text).
type Options ¶
type Options[T any, ID comparable] struct { // EntityType names this entity type in Translation rows. If empty, it is // derived from the struct type T (e.g. Location -> "locations"). EntityType string // Fields lists the storage names of the translatable fields (db-tag // names, e.g. "title", "description"). Required; every name must resolve // to a string field on T. Fields []string // IDFunc extracts the primary key from an entity. Required. IDFunc IDFunc[T, ID] // Preloads declares which preloaded relations carry translatable text, so the // overlay reaches a parent's children and not just the parent. Empty by // default: a relation is translated only when declared. Preloads []Translated // ExcludeStale hides stale translations on reads, falling back to source text. // Default: stale translations are served (usually better than the wrong // language). ExcludeStale bool // SkipOverlay disables the read overlay: Get/List return source text untouched // while Update/Patch still mark translations stale. For editor/admin repos, // where the form shows and saves source text but source edits must still // invalidate translations - the safe answer to the read-modify-write hazard in // the package doc. SkipOverlay bool // DeleteWithEntity removes an entity's translations when it is deleted. Default // false: keeping them is the safe choice with soft-deleted, restorable entities. DeleteWithEntity bool // ErrorHandler is invoked when a best-effort side operation fails (overlay read, // staleness patch, delete cleanup). If nil, failures are logged via slog. // Reads/mutations themselves never fail over it. ErrorHandler func(error) }
Options configures the behavior of a translations CRUD decorator.
type Source ¶
type Source string
Source records where a translation came from, so human work is never overwritten by machines.
const ( // SourceAI marks a machine translation (an LLM/MT worker). Workers may // freely re-translate AI rows when they go stale. SourceAI Source = "ai" // SourceHuman marks a translation written or corrected by a person. // Workers must not overwrite human rows - flag them for review instead. SourceHuman Source = "human" // SourceImport marks a translation that arrived with the data itself // (e.g. a video that ships titles in several languages). SourceImport Source = "import" )
type Translated ¶ added in v0.7.0
type Translated struct {
// contains filtered or unexported fields
}
Translated describes one preloaded relation whose entities carry translatable text. Build it with TranslatedRelation; attach it with WithPreloads.
It is type-erased on purpose. A CRUD[T, ID] has no type parameter for its children, so the child's type is resolved inside TranslatedRelation - where it is still a real type - and reduced to a field map, an id extractor, and the reflect.Type used to check the parent's field at startup.
func TranslatedRelation ¶ added in v0.7.0
func TranslatedRelation[C any, CID comparable]( field string, idFunc func(C) CID, fields ...string, ) Translated
TranslatedRelation declares that the preloaded relation at the parent's field carries translatable text, naming the child's translatable fields by storage name (db-tag name or snake_cased Go name) and how to read the child's id.
field is the Go field name, as in r3.Preloads("City"). The relation's field on the parent may be C, *C, []C or []*C.
This is a read overlay: it never writes to the child's translations, and a parent's Update/Patch does not mark them stale. That belongs to whatever repo owns the child.
type Translation ¶
type Translation struct {
// ID is the unique identifier for this translation row (UUID).
ID string `json:"id" db:"id,pk" bson:"_id"`
// EntityType names the translated entity's type (e.g. "locations",
// "videos"). Derived from the struct type or set via WithEntityType.
EntityType string `json:"entity_type" db:"entity_type" bson:"entity_type"`
// EntityID is the primary key of the translated entity, stringified.
EntityID string `json:"entity_id" db:"entity_id" bson:"entity_id"`
// Field is the storage name of the translated field (e.g. "title") -
// the db tag of the struct field, or its snake_cased Go name.
Field string `json:"field" db:"field" bson:"field"`
// Lang is the language tag of Value (matches r3.GetLocale values).
Lang string `json:"lang" db:"lang" bson:"lang"`
// Value is the translated text. An empty Value is treated as "no
// translation" and never overlaid.
Value string `json:"value" db:"value" bson:"value"`
// ValueNorm is an optional normalized copy of Value for search (case/diacritics
// folded as the app defines). R3 never computes or reads it; it exists so
// translated content is searchable with the app's own normalization.
ValueNorm string `json:"value_norm,omitempty" db:"value_norm" bson:"value_norm,omitempty"`
// Source records who produced this translation: ai, human, or import.
Source Source `json:"source" db:"source" bson:"source"`
// Model optionally names the model that produced an AI translation.
Model string `json:"model,omitempty" db:"model" bson:"model,omitempty"`
// SourceHash is Hash() of the source text this translation was made
// from. The decorator compares it against the current source text on
// Update/Patch to detect staleness.
SourceHash string `json:"source_hash" db:"source_hash" bson:"source_hash"`
// Stale is set when the source text changed after this translation was
// made. Stale translations are still served by default (see
// WithoutStale) and form the re-translation queue for workers.
Stale bool `json:"stale" db:"stale" bson:"stale"`
// CreatedAt is when this translation row was first written.
CreatedAt time.Time `json:"created_at" db:"created_at" bson:"created_at"`
// UpdatedAt is when Value was last (re)written.
UpdatedAt time.Time `json:"updated_at" db:"updated_at" bson:"updated_at"`
}
Translation is one translated value of one field of one entity, in one language - a first-class r3 entity, stored via any r3.CRUD[Translation, string]. Uniqueness is (EntityType, EntityID, Field, Lang); enforce it with a unique index in your storage, and Upsert respects it.
func Upsert ¶
func Upsert(ctx context.Context, store r3.CRUD[Translation, string], tr Translation) (Translation, error)
Upsert writes a translation for (EntityType, EntityID, Field, Lang), updating the existing row or creating it. Identity fields come from tr; Value, ValueNorm, Source, Model, SourceHash are applied; Stale is cleared (an upsert IS the re-translation). Timestamps are set from time.Now.