@endora-commerce/mod-i18n 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/backend/cli/coverage.d.ts +29 -0
  4. package/dist/backend/cli/coverage.d.ts.map +1 -0
  5. package/dist/backend/cli/coverage.js +84 -0
  6. package/dist/backend/cli/coverage.js.map +1 -0
  7. package/dist/backend/cli/reload.d.ts +35 -0
  8. package/dist/backend/cli/reload.d.ts.map +1 -0
  9. package/dist/backend/cli/reload.js +18 -0
  10. package/dist/backend/cli/reload.js.map +1 -0
  11. package/dist/backend/entities/translation-bundle.entity.d.ts +23 -0
  12. package/dist/backend/entities/translation-bundle.entity.d.ts.map +1 -0
  13. package/dist/backend/entities/translation-bundle.entity.js +69 -0
  14. package/dist/backend/entities/translation-bundle.entity.js.map +1 -0
  15. package/dist/backend/index.d.ts +164 -0
  16. package/dist/backend/index.d.ts.map +1 -0
  17. package/dist/backend/index.js +134 -0
  18. package/dist/backend/index.js.map +1 -0
  19. package/dist/backend/routes.admin.d.ts +41 -0
  20. package/dist/backend/routes.admin.d.ts.map +1 -0
  21. package/dist/backend/routes.admin.js +74 -0
  22. package/dist/backend/routes.admin.js.map +1 -0
  23. package/dist/backend/services/bundle-loader.d.ts +49 -0
  24. package/dist/backend/services/bundle-loader.d.ts.map +1 -0
  25. package/dist/backend/services/bundle-loader.js +84 -0
  26. package/dist/backend/services/bundle-loader.js.map +1 -0
  27. package/dist/backend/services/bundle-reconciler.d.ts +82 -0
  28. package/dist/backend/services/bundle-reconciler.d.ts.map +1 -0
  29. package/dist/backend/services/bundle-reconciler.js +38 -0
  30. package/dist/backend/services/bundle-reconciler.js.map +1 -0
  31. package/dist/backend/services/i18n-service.d.ts +67 -0
  32. package/dist/backend/services/i18n-service.d.ts.map +1 -0
  33. package/dist/backend/services/i18n-service.js +323 -0
  34. package/dist/backend/services/i18n-service.js.map +1 -0
  35. package/dist/backend/services/missing-key-logger.d.ts +52 -0
  36. package/dist/backend/services/missing-key-logger.d.ts.map +1 -0
  37. package/dist/backend/services/missing-key-logger.js +37 -0
  38. package/dist/backend/services/missing-key-logger.js.map +1 -0
  39. package/dist/manifest.d.ts +214 -0
  40. package/dist/manifest.d.ts.map +1 -0
  41. package/dist/manifest.js +245 -0
  42. package/dist/manifest.js.map +1 -0
  43. package/dist/migrations/20260507T091405_i18n_admin_i18n_init.d.ts +30 -0
  44. package/dist/migrations/20260507T091405_i18n_admin_i18n_init.d.ts.map +1 -0
  45. package/dist/migrations/20260507T091405_i18n_admin_i18n_init.js +50 -0
  46. package/dist/migrations/20260507T091405_i18n_admin_i18n_init.js.map +1 -0
  47. package/dist/migrations/index.d.ts +23 -0
  48. package/dist/migrations/index.d.ts.map +1 -0
  49. package/dist/migrations/index.js +23 -0
  50. package/dist/migrations/index.js.map +1 -0
  51. package/docs/i18n.md +166 -0
  52. package/i18n/en.json +1957 -0
  53. package/i18n/pl.json +1957 -0
  54. package/package.json +68 -0
@@ -0,0 +1,245 @@
1
+ import { defineModuleManifest, } from '@endora-commerce/contracts';
2
+ /**
3
+ * Admin UI i18n subsystem — feature 019.
4
+ *
5
+ * Platform-internal module (underscore-prefixed exemption per Constitution
6
+ * Principle VI, alongside `auth` / `example` / `_lifecycle`). Owns the
7
+ * `translation_bundles` table, the per-language merged-bundle resolver,
8
+ * the read API consumed by the admin SPA at boot, and the `core`
9
+ * namespace that holds admin-chrome strings (AppShell, navigation,
10
+ * login, profile).
11
+ *
12
+ * Depends on `_lifecycle` because every module's bundle install / hard-
13
+ * uninstall is driven by the lifecycle orchestrator's hook surface.
14
+ */
15
+ export const manifest = defineModuleManifest({
16
+ id: '_i18n',
17
+ name: 'Admin UI i18n',
18
+ // One string literal, not a concatenation: `manifests:generate` reads this
19
+ // field to render the package's `description`, and it reads a literal — a
20
+ // package whose description is computed is one the generator refuses rather
21
+ // than invents a sentence for (feature 080, T041).
22
+ description: 'Per-user Admin UI language preference and module-scoped translation bundles. English is the platform-wide fallback (FR-013 / FR-016).',
23
+ version: '1.0.0',
24
+ // `auth` owns the `requireAdmin` port every route here is gated by;
25
+ // `admin_users` owns the service the preferred-language setter writes through.
26
+ // Neither depends back on this module, so the graph stays acyclic.
27
+ dependencies: ['_lifecycle', 'auth', 'admin_users'],
28
+ i18n: { bundlesDir: 'i18n' },
29
+ docs: { dir: 'docs' },
30
+ // Feature 074 (Constitution XVII), test C1 — reachability. Two grounds hold
31
+ // and they are stated in that order because only the first is about this
32
+ // module's merits: every user-facing string on every surface resolves here,
33
+ // including the labels on `/platform/modules`, so switching it off would
34
+ // leave an operator unable to read the screen that switches it back on. The
35
+ // `_`-prefix rule is the structural second: an `_`-prefixed id is
36
+ // platform-internal and `assertActivationRules`
37
+ // (`packages/contracts/src/modules.ts`) refuses any other activation form
38
+ // for one.
39
+ activation: {
40
+ nonDeactivatable: true,
41
+ reason: 'Every user-facing string on every surface is served from here, including the labels ' +
42
+ 'on the platform screen that holds the module switches.',
43
+ },
44
+ /**
45
+ * **The platform's error-code block — 21 codes, and this is their home** rather
46
+ * than the residue of a migration (feature 090,
47
+ * `specs/090-module-owned-error-codes/core-block-home.md`; D-186 §4).
48
+ *
49
+ * A code is here because **no module owns its noun** — D-121 T3, and nothing
50
+ * weaker. Not because the platform throws it: D-121 rejected the thrower rule
51
+ * by measurement, and four of the entries below name a code exactly one module
52
+ * raises. Each carries its tier and the sentence that argues it in
53
+ * `PLATFORM_OWNED_ERROR_CODES`
54
+ * (`backend/test/fixtures/error-code-routing/reference-ledgers.ts`), which is
55
+ * where a reason can live in a form a test reads — a comment does not survive
56
+ * `tsc`, and the gate reads this list out of the built package.
57
+ *
58
+ * **The gate, and what it is derived from.**
59
+ * `backend/test/unit/_i18n/platform-error-code-block.test.ts` holds this list
60
+ * and those annotations equal in **both** directions: a code added here with no
61
+ * reason of its own is `unannotated` and names itself, and an annotation this
62
+ * list no longer backs is `undeclared`. Neither side is a written-down list of
63
+ * twenty-one (D-100) — the annotations' membership is itself held against
64
+ * `intendedRouting(capture, ledgers)`, the frozen chain capture with the
65
+ * re-homing and minting ledgers laid over it, so what belongs here is the
66
+ * hundred the deleted chain answered `core` for minus the seventy-nine D-129's
67
+ * sweep moved. D-186 §4 chose that over a scanner because both scanners were
68
+ * measured and both are ledgers of exceptions (`d129-sweep.md` §6.1–§6.2),
69
+ * and because a block of twenty-one lines is one a reviewer reads in full.
70
+ *
71
+ * **How it came to be twenty-one.** The list opened as the frozen chain
72
+ * capture and not as a judgement: the verbatim output of the runbook's step-1
73
+ * derivation over `test/fixtures/error-code-routing/chain-answers.ts`, every
74
+ * code `moduleIdForErrorCode` answered `core` for, because
75
+ * `contracts/error-code-declaration.md` §6.2 makes the migration
76
+ * answer-preserving over all 289 codes with no exception list and §6.5 puts
77
+ * re-routing out of scope. Most of that hundred was never the platform's: they
78
+ * fell off the end of a prefix chain that ended in `return 'core'`, which is
79
+ * the defect `specs/082-error-code-ownership/rulings.md` §9 opened the sweep
80
+ * for. D-129's sweep then moved **79 codes into 20 modules** over six batches —
81
+ * Tier A's `KSEF_*` and `PIM_ERGONODE_*` (MR 2) and its six remaining modules
82
+ * (MR 3), Tier B's six that already shipped a bundle (MR 4) and its four that
83
+ * had to create one (MR 5), then Tier C's `admin_roles` (MR 6) and
84
+ * `organizations` (MR 7) — and every one of the 79 carries a
85
+ * `REHOMED_ERROR_CODES` entry naming where it went, which tier decided it and
86
+ * why. The capture itself is untouched, because a reference a migration may
87
+ * rewrite is one that agrees with whatever the migration did.
88
+ *
89
+ * **Do not move one in passing.** A code that leaves this list without arriving
90
+ * in its owner's manifest routes nowhere; one that arrives in both routes to
91
+ * neither, and the collision rule makes that visible at composition rather than
92
+ * at an operator. The three ledgers and the two harnesses all read the same
93
+ * reference, so a half-done move is red in the merge request that made it.
94
+ *
95
+ * **Why `_i18n` and not a module called `core`.** `core` is not a module id. It
96
+ * is the synthetic namespace this module's bundle is exposed under at the
97
+ * resolver boundary (`I18N_CHROME_MODULE_ID` / `CORE_NAMESPACE` in
98
+ * `backend/services/i18n-service.ts`), so the module that owns the platform
99
+ * bundle — the phrase §6.5 uses — is this one. Every sentence for these codes
100
+ * already lives in this package's `i18n/{en,pl}.json`. The alternatives, and
101
+ * why each was refused, are §4 of the design note: a residual set in
102
+ * `packages/contracts` (a second input to a derivation §4 says has one),
103
+ * `_lifecycle` (82 sentence keys moved for no operator-visible gain), and a new
104
+ * registered `_platform` module (a registry row, an install-order position and
105
+ * a `/platform/modules` row, invented to hold a list).
106
+ *
107
+ * **`tokens` is derived from the raise sites, not from the bundle** (runbook
108
+ * §5), and the arithmetic below is **re-derived on this tree rather than
109
+ * decremented** — which is the instruction each batch of the sweep followed and
110
+ * the reason the numbers survived it. Measured over the 21 by balanced-paren
111
+ * extraction of every `new HttpError(...)` call's own arguments: **794 raise
112
+ * sites, four of the codes carrying a `details.code`**. Three of the four
113
+ * declare their tokens here — five tokens in total, of which **two have a
114
+ * sentence in both languages** (`FORBIDDEN.organization_cannot_transact`,
115
+ * `.customer_outside_assignment_scope`) and three do not, each of those three
116
+ * being a Phase 4 finding rather than a silent absence. The fourth is
117
+ * `VALIDATION_FAILED`, which declares **none** although 19 distinct tokens are
118
+ * passed at its raise sites: `localizeErrorEnvelope` returns before translating
119
+ * that code (`packages/platform/src/http/error-envelope.ts`), so its
120
+ * `details.code` values are machine-readable discriminators on the wire and can
121
+ * never key an `errors.VALIDATION_FAILED.<token>` sentence. Declaring them
122
+ * would declare sentences nothing can render. If a code arrives or leaves,
123
+ * re-run the scan; do not adjust the number.
124
+ *
125
+ * **What the bundle holds for these 21**: 16 `errors.*` keys in each language —
126
+ * 14 base sentences and the two token keys above. The seven codes with no
127
+ * sentence are the six `MODULE_*` refusals an operator meets through the
128
+ * lifecycle CLI rather than through the envelope, plus `PRICE_UNAVAILABLE`,
129
+ * which nothing raises. All seven are on `UNTRANSLATED_ERROR_CODES` under this
130
+ * module's group and are drained by writing a sentence, never by moving a code.
131
+ */
132
+ errorCodes: [
133
+ // T3 — the platform's own `MODULE_*` vocabulary (7). The noun is a module,
134
+ // and what installs, composes, gates and withdraws one is the platform.
135
+ // `settings`, `audit_logs`, `product_feeds` and `carts` raise four of these
136
+ // seven and own none of them; the deleted chain reached them through a rule
137
+ // that **named** them (`startsWith('MODULE_')`), which is a decision rather
138
+ // than the fall-through the rest of the block arrived by (`d129-sweep.md`
139
+ // §2.2).
140
+ { code: 'MODULE_ACTIVATION_PROTECTED' },
141
+ { code: 'MODULE_DEPENDENCIES_ABSENT' },
142
+ { code: 'MODULE_DEPENDENTS_PRESENT' },
143
+ { code: 'MODULE_DISABLED' },
144
+ { code: 'MODULE_NOT_DEACTIVATABLE' },
145
+ { code: 'MODULE_NOT_FOUND' },
146
+ { code: 'MODULE_SETTING_READ_ONLY' },
147
+ // T3 — the envelope's generic vocabulary (6). Each of these is what dozens
148
+ // of modules answer with for a condition that is about the request rather
149
+ // than about a noun, so its sentence has to be generic and the specific case
150
+ // is a token on it (issue #65) or an interpolated value (issue #161).
151
+ { code: 'FORBIDDEN', tokens: [
152
+ 'organization_cannot_transact',
153
+ 'customer_outside_assignment_scope',
154
+ 'reorder_disabled',
155
+ ] },
156
+ { code: 'INTERNAL', tokens: ['customer_account_organization_missing'] },
157
+ { code: 'NOT_FOUND' },
158
+ { code: 'UNAUTHORIZED' },
159
+ { code: 'VALIDATION_FAILED' },
160
+ { code: 'VERSION_CONFLICT', tokens: ['organization_version_mismatch'] },
161
+ // T3 by D-122 — a noun with two claimants or none (7). Identical claimants
162
+ // are the proof: `admin_users` and `customer_accounts` each own an account
163
+ // with a password, `orders` and `returns` each own a state machine, and
164
+ // `catalog` and `price_lists` both claim the noun "price".
165
+ // `INVALID_TRANSITION` is the code D-122 was written about.
166
+ { code: 'CURRENT_PASSWORD_INVALID' },
167
+ { code: 'EMAIL_ALREADY_REGISTERED' },
168
+ { code: 'INVALID_CREDENTIALS' },
169
+ { code: 'INVALID_TRANSITION' },
170
+ { code: 'PRICE_UNAVAILABLE' },
171
+ { code: 'TERMS_VERSION_STALE' },
172
+ { code: 'TOKEN_INVALID_OR_EXPIRED' },
173
+ // T3 by declaration (1). The deleted chain named `RATE_LIMITED` in an
174
+ // explicit generic list rather than reaching it by fall-through, which makes
175
+ // it `core-block-home.md` §1.3's counter-example: an explicit T3 with one
176
+ // raiser today. The noun is a request budget, which the platform imposes.
177
+ { code: 'RATE_LIMITED' },
178
+ ],
179
+ });
180
+ /**
181
+ * `translation_bundles` follows the manifest set — feature 080, T036a / D-159.
182
+ *
183
+ * Not an install hook: an install hook fires for *its own* module, and this has
184
+ * to run whenever **any** module is installed, because this module's table is a
185
+ * projection of every other module's `manifest.i18n` declaration. Not a port
186
+ * either: the lifecycle orchestrator also serves the five `module:*` commands,
187
+ * and a platform command composes no container to resolve one from (D-157.2 /
188
+ * D-157.4) — which is exactly how a terminal install came to write no bundle at
189
+ * all while the same install from `/platform/modules` wrote them.
190
+ *
191
+ * The service is imported at call time, not at module load. This file is
192
+ * imported by the generated manifest index, which is in turn imported by every
193
+ * static check script and by `src/db/configured-migrations.ts`; a static import
194
+ * of `I18nService` would pull an ORM-dependent graph into all of them.
195
+ *
196
+ * A fresh `I18nService` per call is correct and not a lost cache:
197
+ * `getMergedBundleForLanguage` revalidates against `MAX(version)` in the table
198
+ * on every read, so the running server picks the new rows up on its next
199
+ * request without having been the instance that wrote them.
200
+ */
201
+ export const lifecycleParticipant = {
202
+ async onModuleInstalled({ moduleId, manifest: installed, modulePath, em }) {
203
+ // The declaration belongs to the module being installed, so the decision
204
+ // is this one's to make: a manifest with no `i18n` block ships no bundle.
205
+ if (!installed.i18n)
206
+ return;
207
+ const { I18nService } = await import('./backend/services/i18n-service.js');
208
+ await new I18nService({ em: () => em }).installBundlesForModule(moduleId, modulePath, installed.i18n.bundlesDir, em);
209
+ },
210
+ async onModuleHardUninstalled({ moduleId, em }) {
211
+ // Unconditional, and deliberately not gated on the manifest's `i18n`
212
+ // block: the manifest is `null` for an orphan row whose module this
213
+ // instance no longer has, and that is the one case whose rows nothing
214
+ // else will ever remove.
215
+ const { I18nService } = await import('./backend/services/i18n-service.js');
216
+ await new I18nService({ em: () => em }).removeBundlesForModule(moduleId, em);
217
+ },
218
+ };
219
+ /**
220
+ * The two operator commands this module declares — feature 080, T042b /
221
+ * D-160.9.
222
+ *
223
+ * They were `scripts/reload.ts` and `scripts/coverage.ts`, each opening its own
224
+ * ORM and building its own `I18nService`. The host composes now and hands the
225
+ * body this module's `ModuleContext`, which is what retired this module's one
226
+ * `check:module-boundary` key: `reload` needed the deployment's module registry,
227
+ * and the registry is a root-supplied name any module may read off its cradle.
228
+ *
229
+ * The bodies are `await import()`ed for the reason the participant above gives:
230
+ * this file is imported by the generated manifest index, and through it by every
231
+ * static check script and by `src/db/configured-migrations.ts`.
232
+ */
233
+ export const cliCommands = [
234
+ {
235
+ name: 'reload',
236
+ summary: "Re-read every module's on-disk i18n bundles into translation_bundles.",
237
+ run: async (context) => (await import('./backend/cli/reload.js')).reload(context),
238
+ },
239
+ {
240
+ name: 'coverage',
241
+ summary: 'Print the per-module, per-language translation coverage snapshot.',
242
+ run: async (context) => (await import('./backend/cli/coverage.js')).coverage(context),
243
+ },
244
+ ];
245
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AACA,OAAO,EACL,oBAAoB,GAGrB,MAAM,4BAA4B,CAAC;AAGpC;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,oBAAoB,CAAC;IAC3C,EAAE,EAAE,OAAO;IACX,IAAI,EAAE,eAAe;IACrB,2EAA2E;IAC3E,0EAA0E;IAC1E,4EAA4E;IAC5E,mDAAmD;IACnD,WAAW,EACT,uIAAuI;IACzI,OAAO,EAAE,OAAO;IAChB,oEAAoE;IACpE,+EAA+E;IAC/E,mEAAmE;IACnE,YAAY,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,aAAa,CAAC;IACnD,IAAI,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE;IAC5B,IAAI,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE;IACrB,4EAA4E;IAC5E,yEAAyE;IACzE,4EAA4E;IAC5E,yEAAyE;IACzE,4EAA4E;IAC5E,kEAAkE;IAClE,gDAAgD;IAChD,0EAA0E;IAC1E,WAAW;IACX,UAAU,EAAE;QACV,gBAAgB,EAAE,IAAI;QACtB,MAAM,EACJ,sFAAsF;YACtF,wDAAwD;KAC3D;IACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAuFG;IACH,UAAU,EAAE;QACV,2EAA2E;QAC3E,wEAAwE;QACxE,4EAA4E;QAC5E,4EAA4E;QAC5E,4EAA4E;QAC5E,0EAA0E;QAC1E,SAAS;QACT,EAAE,IAAI,EAAE,6BAA6B,EAAE;QACvC,EAAE,IAAI,EAAE,4BAA4B,EAAE;QACtC,EAAE,IAAI,EAAE,2BAA2B,EAAE;QACrC,EAAE,IAAI,EAAE,iBAAiB,EAAE;QAC3B,EAAE,IAAI,EAAE,0BAA0B,EAAE;QACpC,EAAE,IAAI,EAAE,kBAAkB,EAAE;QAC5B,EAAE,IAAI,EAAE,0BAA0B,EAAE;QAEpC,2EAA2E;QAC3E,0EAA0E;QAC1E,6EAA6E;QAC7E,sEAAsE;QACtE,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE;gBAC3B,8BAA8B;gBAC9B,mCAAmC;gBACnC,kBAAkB;aACnB,EAAE;QACH,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,uCAAuC,CAAC,EAAE;QACvE,EAAE,IAAI,EAAE,WAAW,EAAE;QACrB,EAAE,IAAI,EAAE,cAAc,EAAE;QACxB,EAAE,IAAI,EAAE,mBAAmB,EAAE;QAC7B,EAAE,IAAI,EAAE,kBAAkB,EAAE,MAAM,EAAE,CAAC,+BAA+B,CAAC,EAAE;QAEvE,2EAA2E;QAC3E,2EAA2E;QAC3E,wEAAwE;QACxE,2DAA2D;QAC3D,4DAA4D;QAC5D,EAAE,IAAI,EAAE,0BAA0B,EAAE;QACpC,EAAE,IAAI,EAAE,0BAA0B,EAAE;QACpC,EAAE,IAAI,EAAE,qBAAqB,EAAE;QAC/B,EAAE,IAAI,EAAE,oBAAoB,EAAE;QAC9B,EAAE,IAAI,EAAE,mBAAmB,EAAE;QAC7B,EAAE,IAAI,EAAE,qBAAqB,EAAE;QAC/B,EAAE,IAAI,EAAE,0BAA0B,EAAE;QAEpC,sEAAsE;QACtE,6EAA6E;QAC7E,0EAA0E;QAC1E,0EAA0E;QAC1E,EAAE,IAAI,EAAE,cAAc,EAAE;KACzB;CACF,CAAC,CAAC;AAEH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAA8C;IAC7E,KAAK,CAAC,iBAAiB,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,EAAE,EAAE;QACvE,yEAAyE;QACzE,0EAA0E;QAC1E,IAAI,CAAC,SAAS,CAAC,IAAI;YAAE,OAAO;QAC5B,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,MAAM,CAAC,oCAAoC,CAAC,CAAC;QAC3E,MAAM,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,uBAAuB,CAC7D,QAAQ,EACR,UAAU,EACV,SAAS,CAAC,IAAI,CAAC,UAAU,EACzB,EAAE,CACH,CAAC;IACJ,CAAC;IACD,KAAK,CAAC,uBAAuB,CAAC,EAAE,QAAQ,EAAE,EAAE,EAAE;QAC5C,qEAAqE;QACrE,oEAAoE;QACpE,sEAAsE;QACtE,yBAAyB;QACzB,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,MAAM,CAAC,oCAAoC,CAAC,CAAC;QAC3E,MAAM,IAAI,WAAW,CAAC,EAAE,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,sBAAsB,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IAC/E,CAAC;CACF,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,WAAW,GAAmD;IACzE;QACE,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,uEAAuE;QAChF,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC,MAAM,MAAM,CAAC,yBAAyB,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC;KAClF;IACD;QACE,IAAI,EAAE,UAAU;QAChB,OAAO,EAAE,mEAAmE;QAC5E,GAAG,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,CAAC,CAAC,MAAM,MAAM,CAAC,2BAA2B,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC;KACtF;CACF,CAAC"}
@@ -0,0 +1,30 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * Admin UI language versions — feature 019 / data-model.md §1.
4
+ *
5
+ * Schema changes (one atomic migration):
6
+ * - new sequence `translation_bundles_version_seq` — monotonically-
7
+ * increasing version vector consumed by the resolver's cache
8
+ * invalidation path (research §R9).
9
+ * - new table `translation_bundles` — one row per
10
+ * `(module_id, language_code)`. JSONB `entries` carries the flat
11
+ * translation key → string map; `version` defaults to
12
+ * `nextval(translation_bundles_version_seq)` so every UPSERT bumps
13
+ * the vector without explicit application logic.
14
+ * - btree index `idx_translation_bundles_language_code` to support
15
+ * `GET /api/v1/admin/i18n/bundles?language=…` which scans by language.
16
+ * - column `admin_users.preferred_language` (nullable) — NULL means
17
+ * "no preference saved", which the resolver treats as `'en'`
18
+ * (feature 019 spec FR-003 / FR-016).
19
+ *
20
+ * No FK on `translation_bundles.module_id`: modules are filesystem-driven
21
+ * (feature 018) and `module_registrations` is the registry of record;
22
+ * adding a FK here would couple lifecycle ordering in ways the existing
23
+ * modules do not have. Cleanup is enforced by the lifecycle hard-uninstall
24
+ * hook, not by ON DELETE CASCADE.
25
+ */
26
+ export declare class Migration20260507T091405I18nAdminI18nInit extends Migration {
27
+ up(): Promise<void>;
28
+ down(): Promise<void>;
29
+ }
30
+ //# sourceMappingURL=20260507T091405_i18n_admin_i18n_init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260507T091405_i18n_admin_i18n_init.d.ts","sourceRoot":"","sources":["../../src/migrations/20260507T091405_i18n_admin_i18n_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,yCAA0C,SAAQ,SAAS;IACvD,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IA4BnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAKrC"}
@@ -0,0 +1,50 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * Admin UI language versions — feature 019 / data-model.md §1.
4
+ *
5
+ * Schema changes (one atomic migration):
6
+ * - new sequence `translation_bundles_version_seq` — monotonically-
7
+ * increasing version vector consumed by the resolver's cache
8
+ * invalidation path (research §R9).
9
+ * - new table `translation_bundles` — one row per
10
+ * `(module_id, language_code)`. JSONB `entries` carries the flat
11
+ * translation key → string map; `version` defaults to
12
+ * `nextval(translation_bundles_version_seq)` so every UPSERT bumps
13
+ * the vector without explicit application logic.
14
+ * - btree index `idx_translation_bundles_language_code` to support
15
+ * `GET /api/v1/admin/i18n/bundles?language=…` which scans by language.
16
+ * - column `admin_users.preferred_language` (nullable) — NULL means
17
+ * "no preference saved", which the resolver treats as `'en'`
18
+ * (feature 019 spec FR-003 / FR-016).
19
+ *
20
+ * No FK on `translation_bundles.module_id`: modules are filesystem-driven
21
+ * (feature 018) and `module_registrations` is the registry of record;
22
+ * adding a FK here would couple lifecycle ordering in ways the existing
23
+ * modules do not have. Cleanup is enforced by the lifecycle hard-uninstall
24
+ * hook, not by ON DELETE CASCADE.
25
+ */
26
+ export class Migration20260507T091405I18nAdminI18nInit extends Migration {
27
+ async up() {
28
+ this.addSql(`create sequence "translation_bundles_version_seq" as bigint;`);
29
+ this.addSql(`
30
+ create table "translation_bundles" (
31
+ "module_id" varchar(64) not null,
32
+ "language_code" varchar(12) not null,
33
+ "entries" jsonb not null default '{}'::jsonb,
34
+ "version" bigint not null default nextval('translation_bundles_version_seq'),
35
+ "installed_at" timestamptz not null default now(),
36
+ "updated_at" timestamptz not null default now(),
37
+ constraint "translation_bundles_pkey" primary key ("module_id", "language_code")
38
+ );
39
+ `);
40
+ this.addSql(`alter sequence "translation_bundles_version_seq" owned by "translation_bundles"."version";`);
41
+ this.addSql(`create index "idx_translation_bundles_language_code" on "translation_bundles" ("language_code");`);
42
+ this.addSql(`alter table "admin_users" add column "preferred_language" varchar(12) null;`);
43
+ }
44
+ async down() {
45
+ this.addSql(`alter table "admin_users" drop column if exists "preferred_language";`);
46
+ this.addSql(`drop table if exists "translation_bundles";`);
47
+ this.addSql(`drop sequence if exists "translation_bundles_version_seq";`);
48
+ }
49
+ }
50
+ //# sourceMappingURL=20260507T091405_i18n_admin_i18n_init.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260507T091405_i18n_admin_i18n_init.js","sourceRoot":"","sources":["../../src/migrations/20260507T091405_i18n_admin_i18n_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,OAAO,yCAA0C,SAAQ,SAAS;IAC7D,KAAK,CAAC,EAAE;QACf,IAAI,CAAC,MAAM,CAAC,8DAA8D,CAAC,CAAC;QAE5E,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;KAUX,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,CACT,4FAA4F,CAC7F,CAAC;QAEF,IAAI,CAAC,MAAM,CACT,kGAAkG,CACnG,CAAC;QAEF,IAAI,CAAC,MAAM,CACT,6EAA6E,CAC9E,CAAC;IACJ,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,uEAAuE,CAAC,CAAC;QACrF,IAAI,CAAC,MAAM,CAAC,6CAA6C,CAAC,CAAC;QAC3D,IAAI,CAAC,MAAM,CAAC,4DAA4D,CAAC,CAAC;IAC5E,CAAC;CACF"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent (D-168).
8
+ *
9
+ * **One class**, and it creates the `translation_bundles` table together with
10
+ * the `translation_bundles_version_seq` sequence the resolver's cache
11
+ * invalidation reads. Its position relative to every other module's is decided
12
+ * by the manifest `dependencies` graph (feature 081); the timestamp orders this
13
+ * module's own migrations and nothing else.
14
+ *
15
+ * The **named** export stays beside the array. `db/migrations-registry.generated.ts`
16
+ * imports the class by name from this specifier, and a migration class name is
17
+ * contract in a way an entity class name is not: `mikro_orm_migrations` persists
18
+ * it, so it is a string every already-migrated database holds.
19
+ */
20
+ import { Migration20260507T091405I18nAdminI18nInit } from './20260507T091405_i18n_admin_i18n_init.js';
21
+ export declare const migrations: (typeof Migration20260507T091405I18nAdminI18nInit)[];
22
+ export { Migration20260507T091405I18nAdminI18nInit };
23
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,yCAAyC,EAAE,MAAM,2CAA2C,CAAC;AAEtG,eAAO,MAAM,UAAU,sDAA8C,CAAC;AAEtE,OAAO,EAAE,yCAAyC,EAAE,CAAC"}
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The `./migrations` subpath — every migration class this module owns, as one
3
+ * ordered `migrations` array.
4
+ *
5
+ * The array is what the platform reads when this module is **installed**:
6
+ * `src/packages/package-runtime.ts` takes `exported['migrations']` and refuses
7
+ * the package outright when it is absent (D-168).
8
+ *
9
+ * **One class**, and it creates the `translation_bundles` table together with
10
+ * the `translation_bundles_version_seq` sequence the resolver's cache
11
+ * invalidation reads. Its position relative to every other module's is decided
12
+ * by the manifest `dependencies` graph (feature 081); the timestamp orders this
13
+ * module's own migrations and nothing else.
14
+ *
15
+ * The **named** export stays beside the array. `db/migrations-registry.generated.ts`
16
+ * imports the class by name from this specifier, and a migration class name is
17
+ * contract in a way an entity class name is not: `mikro_orm_migrations` persists
18
+ * it, so it is a string every already-migrated database holds.
19
+ */
20
+ import { Migration20260507T091405I18nAdminI18nInit } from './20260507T091405_i18n_admin_i18n_init.js';
21
+ export const migrations = [Migration20260507T091405I18nAdminI18nInit];
22
+ export { Migration20260507T091405I18nAdminI18nInit };
23
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,yCAAyC,EAAE,MAAM,2CAA2C,CAAC;AAEtG,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,yCAAyC,CAAC,CAAC;AAEtE,OAAO,EAAE,yCAAyC,EAAE,CAAC"}
package/docs/i18n.md ADDED
@@ -0,0 +1,166 @@
1
+ ---
2
+ title: Admin UI Languages
3
+ description: Admin UI per-user language preference + module-scoped translation bundles
4
+ ---
5
+
6
+ # Admin UI Languages
7
+
8
+ Per-user language preference for the Admin UI plus a module-scoped translation pipeline that
9
+ lets every backend module ship its own bundle of translated strings. Polish and English are
10
+ shipped at launch; English is the platform-wide fallback.
11
+
12
+ The subsystem itself is the workspace package `@endora-commerce/mod-i18n`, at
13
+ `packages/modules/_i18n/` (platform-internal — leading underscore, which the npm name drops).
14
+ Its translation bundles sit at the package root, `packages/modules/_i18n/i18n/`, because the
15
+ platform anchors a module's `bundlesDir` to the module's own directory and a package's own
16
+ directory is where its `package.json` is. The admin SPA's runtime lives at `admin/src/i18n/`.
17
+
18
+ ## How a user changes their language
19
+
20
+ 1. Sign in to the Admin UI.
21
+ 2. Open the **Profile** page (top-right avatar, or the `EN` / `PL` badge in the topbar).
22
+ 3. Pick **English** or **Polski** in the **Language** section, then click **Save language**.
23
+ 4. The whole Admin UI re-renders in the chosen language. No sign-out is required.
24
+
25
+ The choice is persisted on the user's record and follows the user across devices: signing in
26
+ from a different browser or machine yields the same Admin UI language as the most recent
27
+ saved choice.
28
+
29
+ New users (and users who have never made a choice) see English by default.
30
+
31
+ ## Public surface
32
+
33
+ | Verb + Path | Purpose |
34
+ | --- | --- |
35
+ | `GET /api/v1/admin/i18n/bundles?language=<en\|pl>` | Returns the merged `core` + per-module bundle for the requested language plus a monotonically-non-decreasing `version` vector the SPA uses to detect refreshes. Permission: any authenticated admin. |
36
+ | `PATCH /api/v1/admin/me/preferred-language` | Sets the calling user's preferred language. Body: `{ "preferredLanguage": "en" \| "pl" \| null }`. Idempotent; `null` reverts to "no preference saved" → English fallback. Permission: any authenticated admin. |
37
+
38
+ The session-bootstrap response (`GET /api/v1/admin/me`) carries `preferredLanguage` in the
39
+ `adminUser` object so the SPA can seed its `<TranslationProvider>` without an extra round-trip.
40
+
41
+ ## How a module ships translations
42
+
43
+ 1. Declare the bundle directory in your manifest:
44
+
45
+ ```typescript
46
+ // packages/modules/<my_module>/src/manifest.ts
47
+ import { defineModuleManifest } from '@endora-commerce/contracts';
48
+
49
+ export const manifest = defineModuleManifest({
50
+ id: 'my_module',
51
+ name: 'My module',
52
+ version: '1.0.0',
53
+ dependencies: [],
54
+ i18n: { bundlesDir: 'i18n' }, // ← add this
55
+ });
56
+ ```
57
+
58
+ 2. Author per-language JSON files at `packages/modules/<my_module>/i18n/<lang>.json` —
59
+ the package root, not under `src/`, because `bundlesDir` is resolved against the
60
+ directory holding the module's `package.json`.
61
+ The shape is a flat `Record<string, string>` — keys are dotted (`"actions.save"`),
62
+ values are strings. `{name}`-style placeholders are interpolated at runtime.
63
+
64
+ ```jsonc
65
+ // packages/modules/my_module/i18n/en.json
66
+ {
67
+ "actions.save": "Save",
68
+ "validation.required": "This field is required.",
69
+ "audit.userCreated": "Created user \"{email}\"."
70
+ }
71
+ ```
72
+
73
+ 3. Replace inline strings in the admin code with `useTranslation(scope)` calls:
74
+
75
+ ```tsx
76
+ import { useTranslation } from '@/i18n/useTranslation';
77
+
78
+ export function MyForm() {
79
+ const t = useTranslation('my_module');
80
+ return <button>{t('actions.save')}</button>;
81
+ }
82
+ ```
83
+
84
+ 4. Restart (or re-install) the backend. The boot-time bundle reconciler walks every module
85
+ declaring `manifest.i18n` and refreshes its `translation_bundles` rows from the JSON files
86
+ on disk. Failures are logged but do not abort boot.
87
+
88
+ ### Rules and constraints
89
+
90
+ - The supported set is currently `['en', 'pl']` (closed enum in `@endora-commerce/contracts/src/admin-i18n.ts`).
91
+ - Files for unsupported languages are rejected at install time.
92
+ - A module that ships any bundle MUST ship `en.json` (English is the platform-wide fallback).
93
+ A Polish bundle is encouraged but optional — ship it in the same PR as the English file.
94
+ - Two modules MAY use the same key string (for example `actions.save`); each bundle is scoped
95
+ to its owning module so the strings do not collide.
96
+ - User-authored content (CMS pages, product names, blog posts, setting *values*) is **out of
97
+ scope** for this feature — it is rendered as authored, not re-translated.
98
+
99
+ ## How the resolver picks a string
100
+
101
+ Every lookup goes through a three-step fallback chain:
102
+
103
+ 1. The user's preferred-language entry under the module's scope.
104
+ 2. The English entry under the module's scope.
105
+ 3. A literal placeholder `${scope}.${key}` (always non-empty so the UI never goes blank).
106
+
107
+ Both the backend resolver (`I18nService.translate(...)`) and the admin SPA resolver
108
+ (`admin/src/i18n/resolver.ts`) share the same chain.
109
+
110
+ ## Diagnosing missing translations
111
+
112
+ When the resolver falls back, the backend writes a structured log line through the platform's
113
+ existing logger:
114
+
115
+ ```json
116
+ { "event": "i18n.fallback", "moduleId": "settings", "languageCode": "pl", "key": "actions.save", "fellBackTo": "en" }
117
+ ```
118
+
119
+ Operators can answer "what's missing in our Polish bundle?" with a single grep:
120
+
121
+ ```bash
122
+ grep '"event":"i18n.fallback"' /var/log/b2b-backend.log | jq -r '"\(.languageCode) \(.moduleId).\(.key) → \(.fellBackTo)"' | sort -u
123
+ ```
124
+
125
+ The admin SPA emits the same gap as a `console.warn` in development (`import.meta.env.DEV`)
126
+ and is silent in production.
127
+
128
+ ## Database
129
+
130
+ One MikroORM migration (`040_admin_i18n_init.ts`) introduces:
131
+
132
+ - Sequence `translation_bundles_version_seq` (the cache-invalidation vector).
133
+ - Table `translation_bundles` — one row per `(module_id, language_code)`, JSONB `entries`,
134
+ `version` defaults to `nextval(translation_bundles_version_seq)` so every UPSERT advances
135
+ the sequence.
136
+ - Column `admin_users.preferred_language` (varchar(12), nullable). NULL means "no preference
137
+ saved" → resolver treats as English.
138
+
139
+ No FK on `translation_bundles.module_id` — modules are filesystem-driven and the
140
+ hard-uninstall hook is the cleanup mechanism, not `ON DELETE CASCADE`.
141
+
142
+ ## Adding a third language
143
+
144
+ Adding `de` (or any other BCP-47 code) is treated as a separate feature because the cost is
145
+ mostly translation work, not engineering. The schema change is one line in
146
+ `@endora-commerce/contracts/src/admin-i18n.ts`:
147
+
148
+ ```typescript
149
+ export const SupportedAdminLanguageSchema = z.enum(['en', 'pl', 'de']);
150
+ ```
151
+
152
+ After that, each module that emits admin-visible strings must ship `de.json` (or accept the
153
+ EN fallback). The Profile page's selector picks up the new code automatically.
154
+
155
+ ## Testing
156
+
157
+ Unit tests live next to the implementation:
158
+
159
+ - `backend/test/unit/_i18n/bundle-loader.unit.test.ts` — every `BundleLoadError` reason path
160
+ (parse-failed, invalid-shape, unsupported-language-file, missing-fallback-bundle) plus the
161
+ happy path and EN-only acceptance.
162
+ - `backend/test/unit/_i18n/missing-key-logger.unit.test.ts` — structured log line shape.
163
+ - `backend/test/unit/_i18n/i18n-service.unit.test.ts` — the three-step fallback chain plus
164
+ interpolation.
165
+ - `admin/test/i18n/resolver.test.ts` — the same chain on the SPA side.
166
+ - `admin/test/i18n/interpolate.test.ts` — placeholder regex.