/** * i18nText schema type — * * `i18nText({ languages, required })` creates a descriptor for a * multi-language content field whose value is stored as a * `{ [locale]: string }` map (e.g. `{ en: 'Consulting', th: 'ที่ปรึกษา' }`). * * On put, the descriptor validates that required languages are present. * On read (when a `locale` option is passed), the map is collapsed to the * caller's locale string via the fallback chain. * * Design decisions * ──────────────── * * **Descriptor pattern (not a Zod type).** * `i18nText()` returns a plain descriptor object used in the collection's * `i18nFields` option — same pattern as `ref()` / `dictKey()`. This keeps * `@noy-db/core` at zero runtime dependencies and avoids Zod v3 field-type * constraints. TypeScript inference is handled via the descriptor's type. * * **Enforcement at the collection boundary.** * The `required` option is checked by `Collection.put()` via the compartment's * registered `i18nFields`. Failed validation throws `MissingTranslationError` * — a distinct class from `SchemaValidationError` so callers can tell * "wrong shape" from "missing translations". * * **Resolution is post-decryption.** * Locale resolution happens AFTER `decryptRecord()`, as a pure in-memory * transform. No additional crypto work is needed. The resolved record is * returned in place of the stored one, with i18nText fields replaced by * their locale-resolved strings. * * **`locale: 'raw'`.** * Passing `{ locale: 'raw' }` skips resolution and returns the full * `{ [locale]: string }` map — useful for bilingual exports, admin UIs, * and any context where all translations must be visible at once. * * **Out of scope.** * Pluralization, RTL rendering, date/number formatting, per-locale CRDT * merging. */ import type { OnMissing, OnMissingPolicy, Layer } from './policy.js'; /** Flatten an intersection into a single object literal for nicer hovers. */ type Prettify = { [K in keyof T]: T[K]; } & {}; /** * The stored shape of a multilingual field, inferred from its `required` * mode — so the compiler forces you to handle an absent optional locale * (`string | undefined`) instead of silently yielding `undefined`. * * Mirrors `i18nText({ languages, required })`: * - `'all'` (default) — every locale required: `{ th: string; en: string }` * - `'any'` — every locale optional: `{ th?: string; en?: string }` * (the "at least one present" guarantee is runtime-only — not expressible * in TypeScript — so each key is optional) * - `readonly L[]` — listed locales required, the rest optional: * `I18nMap<'th'|'en', ['th']>` → `{ th: string; en?: string }` * * @example * ```ts * type Lang = 'th' | 'en' * interface Contact { * name: I18nMap // { th?: string; en?: string } * legalName: I18nMap // { th: string; en?: string } * slug: I18nMap // { th: string; en: string } * } * ``` * * @public */ export type I18nMap = Required extends 'all' ? Record : Required extends 'any' ? Partial> : Required extends readonly (infer R extends Langs)[] ? Prettify & Partial, string>>> : never; /** * Options for `i18nText()`. * * `languages` declares the full set of supported locales. `required` * controls which must be present on every `put()`. * * `autoTranslate` is the per-field opt-in for the `plaintextTranslator` * hook. When `true` and a `plaintextTranslator` is configured * on `createNoydb()`, missing translations are generated before `put()`. * Default: `false`. */ export interface I18nTextOptions { /** All supported locale codes (BCP 47). */ readonly languages: readonly string[]; /** * Which locales must be present on every `put()`. * * - `'all'` — every declared language must be present. * - `'any'` — at least one declared language must be present. * - `string[]` — listed locales are required; others are optional. */ readonly required: 'all' | 'any' | readonly string[]; /** * Per-field opt-in for the `plaintextTranslator` hook. * When `true`, missing required translations are auto-generated * before `put()` if a translator is configured. Default: `false`. */ readonly autoTranslate?: boolean; /** * What to do when this field is resolved to a locale that is absent. * A single policy, or a per-layer map (read/guard/join/mv/derivation/ * export). Default `'throw'` — today's behavior, zero breaking change. * See {@link OnMissingPolicy}. * * NOTE (current wiring): ALL layers are enforced — `read` (`get`/`list`), * `guard`, `derivation`, `mv`, `join`, `export`. Guard / derivation * `ctx.vault` reads resolve under their own layer policy (`guard` defaults to * the lenient `'substitute'`). The `mv` layer fires for materialized views * that declare `{ i18nLocale, i18nFields }` — UNION (group-key i18n fields * resolve before the unified-row bucketing) and query-form (resolved in * `GroupedAggregation.run` before `groupAndReduce`); grouping a raw i18n field * without a locale throws. The `join` layer resolves a joined right-side i18n * field to the query locale (`toArray({ locale })` or the vault default; raw * when locale-less). The `export` layer fires for * `exportStream`/`exportJSON({ resolveLabels })` — records collapse to the * export locale. */ readonly onMissing?: OnMissingPolicy; /** * Ordered preferred-substitute locales used when `onMissing` resolves * to `'substitute'` and the target locale is absent. `'any'` as an * element means "first non-empty value". A caller-supplied `fallback` * at read time takes precedence over this declared list. */ readonly substitute?: readonly string[]; /** * Smart-substitute. When `true`, a missing-locale `substitute` walk that * misses the explicit chain prefers the available locale whose script is * nearest the target (same script, then Latin) rather than an arbitrary value * — e.g. a missing Thai label prefers another Thai (or Latin) translation over * an unreadable script. Default `false` (legacy first-non-empty behavior). */ readonly smartSubstitute?: boolean; /** * Per-locale script enforcement (write-time). `'auto'` infers the * allowed Unicode scripts per locale (asymmetric Latin tolerance); an * object overrides per slot. Absent ⇒ no check. See `./script.ts`. */ readonly script?: 'auto' | Partial>; /** * What to do when a slot's value contains characters outside its * allowed script set. Default `'reject'`. */ readonly onScriptViolation?: 'reject' | 'filter' | 'warn'; /** * Eager-fill empty locale slots from the substitute chain at * write time, recording provenance in the internal `_i18nFilled` marker. * Mutually exclusive with an EXPLICIT `'throw'` onMissing policy (densify * fills every hole, so a throw would be unreachable). Without an explicit * `substitute`, fills from `'any'` (first non-empty). Default absent. */ readonly densifyOnWrite?: boolean; } /** * Descriptor returned by `i18nText()`. Attach to the collection's * `i18nFields` option: * * ```ts * const lineItems = company.collection('line-items', { * i18nFields: { * description: i18nText({ languages: ['en', 'th'], required: 'all' }), * }, * }) * ``` */ export interface I18nTextDescriptor { readonly _noydbI18nText: true; /** Via port brand marker — lets an `I18nTextDescriptor` satisfy the kernel's opaque `ViaDescriptor` (#623 Task 9). */ readonly _viaBrand: 'i18n'; readonly options: I18nTextOptions; } /** * Create an `I18nTextDescriptor` for a multi-language content field. * * @param options Language list + enforcement mode. * * @example * ```ts * i18nText({ languages: ['en', 'th'], required: 'all' }) * i18nText({ languages: ['en', 'th'], required: ['th'], autoTranslate: true }) * ``` */ export declare function i18nText(options: I18nTextOptions): I18nTextDescriptor; export { isI18nTextDescriptor } from '../../port/with/i18n-strategy.js'; /** * Validate that a value is a valid `{ [locale]: string }` map and that * all required locales are present. Throws `MissingTranslationError` * when the required constraint is violated. * * Called by `Collection.put()` for each registered `i18nField`. * * @param value The raw field value from the record being put. * @param field The field name (used in the thrown error message). * @param descriptor The `i18nText()` descriptor for this field. */ export declare function validateI18nTextValue(value: unknown, field: string, descriptor: I18nTextDescriptor): void; /** * Resolve an i18nText value (`{ [locale]: string }` map) to a string * for the given locale. * * @param value The stored locale map. * @param locale The requested locale code, or `'raw'` to return the map. * @param fallback Single locale or ordered list; use `'any'` as the last * element to fall back to any available translation. * @param field Field name used in `LocaleNotSpecifiedError` messages. * @returns The resolved string, OR the original map when `locale === 'raw'`. */ /** Options for the policy-aware form of {@link resolveI18nText}. */ export interface ResolveI18nOptions { /** Effective policy for the resolution layer. Default `'throw'`. */ readonly policy?: OnMissing; /** Declared substitute chain; applied only under policy `'substitute'`. */ readonly substitute?: readonly string[]; /** * Smart-substitute. When `true` and policy is `'substitute'`, after the * explicit chain misses, pick the available locale whose script is nearest the * target (same script first, then Latin's broad readability) instead of an * arbitrary value. Default `false`. */ readonly smartSubstitute?: boolean; } export declare function resolveI18nText(value: Record, locale: string, fallback?: string | readonly string[], field?: string): string | Record; export declare function resolveI18nText(value: Record, locale: string, fallback: string | readonly string[] | undefined, field: string | undefined, opts: ResolveI18nOptions): string | Record | null; export { getAtPath } from '../../kernel/paths.js'; /** * Apply locale resolution to a single record, returning a new copy. * * For each field registered as an `i18nText` descriptor: * - If `locale === 'raw'`, the field value is left as the stored map. * - Otherwise, the field value is replaced with the resolved string. * * Field paths support dot notation (`'address.lineOne'`) and array * wildcards (`'contacts[].title'`). Top-level fields work as before. * * @param record The decrypted record. * @param i18nFields Map of field path → `I18nTextDescriptor`. * @param locale The requested locale (or `'raw'`). * @param fallback Fallback chain (optional). * @param layer Resolution layer (default `'read'`). Each field's * `onMissing` policy is resolved for this layer, so the * same record resolves leniently on a get but strictly * inside an mv/derivation. */ export declare function applyI18nLocale(record: Record, i18nFields: Record, locale: string, fallback?: string | readonly string[], layer?: Layer): Record; /** * Sync i18n-text-only present-for-join dressing — the exact `join`-layer * partial application `kernel/query/join.ts` used to import and call * directly before #626's retirement (#650 Task 6): fixes `fallback` * `undefined` and `layer` `'join'`. `via/lookup/snapshot.ts`'s * `buildPresentForJoin` composes this with the lookup-label half to build * the `JoinableSource.presentForJoin` hook the join executor now calls * instead of reaching into this module. */ export declare function presentI18nForJoin(record: Record, i18nFields: Record, locale: string): Record; /** * Remove the internal densify provenance marker (`_i18nFilled`) from a * read-facing record. NON-mutating: returns the same object when the * marker is absent, otherwise a shallow copy without the marker. * * MUST be applied on every user-facing read return — even locale-less ones, * which bypass {@link applyI18nLocale}. MUST NOT be applied on the internal * prior-read path (decryptRecord / resolveDensifyPrior), where densify needs * the marker. */ export declare function stripI18nFilled>(record: T): T;