i18n

package
v0.0.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 9, 2026 License: MIT Imports: 12 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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

func Hash(sourceText string) string

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

func QueryFor(entityType, entityID, lang string, fields ...string) r3.Query

QueryFor matches the translations of one entity in one language; pass fields to narrow to specific translated fields (none = all fields).

func QueryForBatch

func QueryForBatch(entityType, lang string, entityIDs []string) r3.Query

QueryForBatch matches the translations of many entities in one language — the single query behind List overlays (never N+1).

func QueryForEntity

func QueryForEntity(entityType, entityID string) r3.Query

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

func QueryStale(entityType string) r3.Query

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

func (c *CRUD[T, ID]) Aggregate(ctx context.Context, qarg ...r3.Query) ([]r3.AggregateRow, error)

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]) Count

func (c *CRUD[T, ID]) Count(ctx context.Context, qarg ...r3.Query) (int64, error)

Count returns the number of matching entities. Nothing to localize.

func (*CRUD[T, ID]) Create

func (c *CRUD[T, ID]) Create(ctx context.Context, entity T) (T, error)

Create inserts a new entity. No translations can exist yet, so it is a pure passthrough.

func (*CRUD[T, ID]) Delete

func (c *CRUD[T, ID]) Delete(ctx context.Context, id ID) error

Delete removes an entity; with WithDeleteOnEntityDelete it also removes the entity's translations (best-effort).

func (*CRUD[T, ID]) Get

func (c *CRUD[T, ID]) Get(ctx context.Context, id ID, qarg ...r3.Query) (T, error)

Get retrieves an entity and overlays translations for the context locale.

func (*CRUD[T, ID]) Inner

func (c *CRUD[T, ID]) Inner() r3.CRUD[T, ID]

Inner returns the underlying CRUD repository (unwrapped).

func (*CRUD[T, ID]) List

func (c *CRUD[T, ID]) List(ctx context.Context, qarg ...r3.Query) ([]T, int64, error)

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

func (c *CRUD[T, ID]) Patch(ctx context.Context, entity T, fields r3.Fields) (T, error)

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

func (c *CRUD[T, ID]) Rewrap(inner r3.CRUD[T, ID]) r3.CRUD[T, ID]

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

func (c *CRUD[T, ID]) Unwrap() r3.CRUD[T, ID]

Unwrap returns the wrapped CRUD so capability detection and transaction propagation can walk the decorator chain.

func (*CRUD[T, ID]) Update

func (c *CRUD[T, ID]) Update(ctx context.Context, entity T) (T, error)

Update modifies an entity, then marks translations whose source text changed as stale (best-effort; the update itself never fails because of it).

func (*CRUD[T, ID]) Upsert added in v0.0.4

func (c *CRUD[T, ID]) Upsert(ctx context.Context, entity T, opts ...r3.UpsertOption) (T, error)

Upsert inserts-or-updates via the inner CRUD, then marks translations whose source text changed as stale — the same best-effort staleness tracking as Update/Patch. Returns r3.ErrUpsertNotSupported when the inner repo has no upsert capability.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL