Documentation
¶
Overview ¶
Package i18n provides entity-content translations for r3 CRUD repositories.
It works as a decorator around any r3.CRUD[T, ID]: on reads (Get, List) it overlays translated values onto the entity's translatable fields for the locale carried in the context (r3.WithLocale / r3.GetLocale); on writes (Update, Patch) it detects source-text changes and marks the affected translations stale, so a translation worker knows what to redo. The decorator is transparent: the wrapped repository still satisfies r3.CRUD[T, ID].
"Everything is a R3po" — translations are themselves r3 entities, stored via any r3.CRUD[Translation, string]. The feature has zero knowledge of storage backends: 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 through the decorator localizes itself.
- Batched overlay: List fetches all translations for the returned page in ONE store query (entity_id IN (...)), never N+1.
- Fallback to source: a field with no (or empty) translation keeps the entity's original text.
- Staleness via SourceHash: each Translation records 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 because of it). Stale translations are still served by default — an outdated translation usually beats the wrong language — pass WithoutStale to hide them.
- Delete: entity deletion 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 would be written over the source text. Keep mutation paths on the unwrapped repository (or ensure their contexts carry no locale).
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 (background 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 WithoutOverlay[T any, ID comparable]() Option[T, ID]
- func WithoutStale[T any, ID comparable]() Option[T, ID]
- type Options
- type Source
- 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 does not exist on the entity struct or is not a string. Raised (as a panic, wrapping this error) at WithTranslations time — misconfigured translatable fields are a programming error, caught at wiring, not at request time.
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) — the re-translation queue for a worker. Entities that have NO translation yet are not visible here; finding those needs an anti-join ("has no translation") against the entity table, which r3's relationship filters (r3.Has) cannot express yet — negated relations are the missing piece — so applications write that one queue query natively for now.
Types ¶
type CRUD ¶
type CRUD[T any, ID comparable] struct { // contains filtered or unexported fields }
CRUD is a decorator that wraps any r3.CRUD[T, ID] and localizes reads: Get and List overlay translated field values for the context locale (r3.GetLocale); Update and Patch mark translations of changed source fields stale. It transparently satisfies r3.CRUD[T, ID].
The translation store is any r3.CRUD[Translation, string] — the same CRUD abstraction used everywhere in r3. "Everything is a R3po."
Wrap read-facing repositories 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 an existing r3.CRUD with locale-aware reads and staleness tracking. IDFunc and Fields are required. It panics on a misconfiguration (unknown or non-string field, missing IDFunc) — wiring errors should fail at startup, not at request time.
repo := i18n.WithTranslations[Location, int64](
inner, store,
i18n.WithIDFunc[Location, int64](func(l Location) int64 { return l.ID }),
i18n.WithFields[Location, int64]("title", "description"),
)
func (*CRUD[T, ID]) Aggregate ¶ added in v0.0.3
Aggregate passes through to the inner CRUD's Aggregate. Aggregated values are not localized: grouped rows carry counts/sums over source-language columns, not translatable text.
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 to the inner CRUD. Aggregated values are not localized (they are counts/sums, not translatable text).
func (*CRUD[T, ID]) Create ¶
Create inserts a new entity. No translations can exist yet, so it is a pure passthrough.
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 source text from, and this capability is intended for 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 a different inner CRUD (used to re-apply 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 capability detection and transaction propagation can walk the decorator chain.
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 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] // ExcludeStale hides stale translations on reads, falling back to the // source text, instead of serving the outdated translation (default: // stale translations are served — usually better than the wrong // language). ExcludeStale bool // SkipOverlay disables the read overlay entirely: Get/List return source // text untouched while Update/Patch still mark translations stale. Use it // on editor/admin repositories, where the form must show (and save) the // source text but source edits still need to invalidate translations — // this is the safe answer to the read-modify-write hazard in the package // doc. SkipOverlay bool // DeleteWithEntity removes an entity's translations when the entity is // deleted. Default false: translations are kept, which is the safe // choice with soft-deleted entities that may be restored. 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 because of 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 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
// (fold case/diacritics as the application defines). R3 never computes
// or reads it — it exists so translated content is searchable with the
// same normalization the application applies to source text.
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. It is a first-class r3 entity: store it via any r3.CRUD[Translation, string] implementation (SQL, GORM, MongoDB, ...).
Uniqueness is (EntityType, EntityID, Field, Lang) — enforce it with a unique index in your storage; 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 when one exists, creating it otherwise. The row's identity fields come from tr; Value, ValueNorm, Source, Model, SourceHash are applied; Stale is cleared (an upsert IS the re-translation). Timestamps are maintained via years-agnostic time.Now.