Appearance
translations
Concept layer — frozen. The Translations generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.
Feature: Translations
Entity: translations
Description: Translation entity holds data about all translation values used across the system.
Entity Type: Table
| Attribute Name | Description | Data Type | Default Value | Required (= Nullable) | Unique | Format | Validations | Index | Example |
|---|---|---|---|---|---|---|---|---|---|
id | Unique identifier for translation | UUID v7 | Auto-generated (in code) | Yes | Yes | - | Must be a unique identifier | Primary Key | 550e8400-e29b-41d4-a716-446655440000 |
locale | Translation locale — one spelling, validated by pattern (see the feature page) | String | - | Yes | No | Language + region; the project picks hyphen or underscore | Must match the project's locale pattern | name: idx_translations_locale, type: btree | cs-CZ |
translationKey | Key being translated | String | - | Yes | No | - | Must not be an empty string | name: idx_translations_translationKey, type: btree | enum.education.primary, entity.account.example_bank_czk |
translationType | Category of the translation — for example enum, entity, static content | String | - | Yes | No | Should be an enum defined by your system | Must not be an empty string | name: idx_translations_translationType, type: btree | entity.account |
translation | Translated text in the required language | String | - | Yes | No | - | Must not be an empty string | — (no documented read filters on the text; see Reads, deletes and indexing) | Základní škola |
components | Component the translation is relevant for — a tag, not part of the identity | Text[] | - | Yes | No | Should be an enum defined by your system | Must not be an empty string | name: idx_translations_components, type: GIN (array overlap) | {web,mobile} |
active | Indicates whether the translation is active or not | Boolean | True | Yes | No | - | - | - | true |
createdAt | Timestamp of entity creation | timestamp with time zone | now() — assigned in code | Yes | No | ISO 8601 — YYYY-MM-DDTHH:mm:ss.SSSZ | - | - | 2025-03-16T18:00:00Z |
updatedAt | Timestamp of last entity update | timestamp with time zone | now() — assigned in code | Yes | No | ISO 8601 — YYYY-MM-DDTHH:mm:ss.SSSZ | - | - | 2025-03-16T18:00:00Z |
deletedAt | Timestamp of entity soft delete. Once set (that is, once it is non-null), this value is immutable and cannot be changed. | timestamp with time zone | - | No | - | ISO 8601 — YYYY-MM-DDTHH:mm:ss.SSSZ | - | - | null |
createdBy | Identifier of the author who created this record. | String | - | Yes | No | type:actor | - | - | user:c2d3f586-1c9a-4f5f-b9ae-45f2c4f69f7e |
updatedBy | String that identifies the author who last updated this record. | String | - | Yes | No | type:actor | - | - | system:migration |
Example Data
sql
INSERT INTO translations (id, locale, translationKey, translation, translationType, components,
active, createdAt, updatedAt, deletedAt, createdBy, updatedBy)
VALUES
('01964a02-6990-7269-a83f-923f6308ca70', 'en-GB', 'app.offline.title',
'No workspace connected', 'static', ARRAY['web'], true,
'2025-04-18T18:45:00+02:00', '2025-04-18T18:45:00+02:00', null, 'seed:init', 'seed:init'),
('01964a02-6991-7269-80d9-a6e0dc470bce', 'en-GB', 'app.offline.description',
'Connect to your workspace by scanning the QR code or tapping the link in your email invitation.',
'static', ARRAY['web'], true,
'2025-04-18T18:45:00+02:00', '2025-04-18T18:45:00+02:00', null, 'seed:init', 'seed:init'),
-- Lookup table values
('01964a0c-7b3c-7c7b-8abb-ac8e6a13f811', 'en-GB', 'cs-CZ', 'Czech', 'lt.locale',
ARRAY['web'], true,
'2025-04-18T18:45:00+02:00', '2025-04-18T18:45:00+02:00', null, 'seed:init', 'seed:init'),
('01964a0c-7b3c-7c7b-8aca-ab66f659fdfc', 'en-GB', 'en-GB', 'English', 'lt.locale',
ARRAY['web'], true,
'2025-04-18T18:45:00+02:00', '2025-04-18T18:45:00+02:00', null, 'seed:init', 'seed:init'),
('01964a0c-7b3c-7c7b-8526-a1783966de5c', 'cs-CZ', 'cs-CZ', 'Čeština', 'lt.locale',
ARRAY['web'], true,
'2025-04-18T18:45:00+02:00', '2025-04-18T18:45:00+02:00', null, 'seed:init', 'seed:init'),
('01964a0c-7b3c-7c7b-89e7-8dd7fe713d9f', 'cs-CZ', 'en-GB', 'Angličtina', 'lt.locale',
ARRAY['web'], true,
'2025-04-18T18:45:00+02:00', '2025-04-18T18:45:00+02:00', null, 'seed:init', 'seed:init');Identity
A translation is identified by locale + translationKey + translationType. Those three columns are unique together among live rows; a write for an existing triple is an update, not a second row.
components is not part of the identity. It records which clients need this translation so a client can fetch only its own subset — it is a tag on the row, not a discriminator. Two modules that genuinely need different text for a similar concept use different keys (<module>.<thing>.<property>), which is what a dot-namespaced key is for.
Why identity stops at three. Consumers are typically served a flat
key → stringmap for one locale. If a key could appear more than once in a locale, that map cannot represent both values and one silently wins. Addingcomponentsto the identity also makes uniqueness depend on an array value, so reordering a list produces a second row rather than a conflict — a duplicate created by a formatting change.
Reads, deletes and indexing
- Delete is soft. The entity defines
deletedAtas immutable once set, and the sibling concepts in this layer soft-delete; a hardDELETEhere would be the only one, and would contradict this document's own column. - Every read filters
active = trueanddeletedAt IS NULLunless it is the administration listing explicitly asking for them. A read that ignores both makes the two columns decorative: deactivating a translation would change nothing a caller sees. - Index what is filtered on. Reads select by
locale,translationTypeandcomponents; those are the columns to index,componentswith a container index since it is an array. Indexing the translated text — which no documented query filters on — buys nothing, and leavingtranslationTypeunindexed while it appears in everyWHEREclause is the wrong way round. Both source documents had exactly that inversion.