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.
- 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).
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 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 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 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 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 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.