@endora-commerce/mod-languages 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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/backend/entities/language.entity.d.ts +23 -0
  4. package/dist/backend/entities/language.entity.d.ts.map +1 -0
  5. package/dist/backend/entities/language.entity.js +78 -0
  6. package/dist/backend/entities/language.entity.js.map +1 -0
  7. package/dist/backend/index.d.ts +63 -0
  8. package/dist/backend/index.d.ts.map +1 -0
  9. package/dist/backend/index.js +114 -0
  10. package/dist/backend/index.js.map +1 -0
  11. package/dist/backend/routes.d.ts +28 -0
  12. package/dist/backend/routes.d.ts.map +1 -0
  13. package/dist/backend/routes.js +74 -0
  14. package/dist/backend/routes.js.map +1 -0
  15. package/dist/backend/services/language-ports.d.ts +33 -0
  16. package/dist/backend/services/language-ports.d.ts.map +1 -0
  17. package/dist/backend/services/language-ports.js +70 -0
  18. package/dist/backend/services/language-ports.js.map +1 -0
  19. package/dist/backend/services/language-reference-registry.d.ts +30 -0
  20. package/dist/backend/services/language-reference-registry.d.ts.map +1 -0
  21. package/dist/backend/services/language-reference-registry.js +54 -0
  22. package/dist/backend/services/language-reference-registry.js.map +1 -0
  23. package/dist/backend/services/language-seed-service.d.ts +24 -0
  24. package/dist/backend/services/language-seed-service.d.ts.map +1 -0
  25. package/dist/backend/services/language-seed-service.js +40 -0
  26. package/dist/backend/services/language-seed-service.js.map +1 -0
  27. package/dist/backend/services/language-service.d.ts +99 -0
  28. package/dist/backend/services/language-service.d.ts.map +1 -0
  29. package/dist/backend/services/language-service.js +299 -0
  30. package/dist/backend/services/language-service.js.map +1 -0
  31. package/dist/backend/services/locale-service.d.ts +35 -0
  32. package/dist/backend/services/locale-service.d.ts.map +1 -0
  33. package/dist/backend/services/locale-service.js +88 -0
  34. package/dist/backend/services/locale-service.js.map +1 -0
  35. package/dist/manifest.d.ts +169 -0
  36. package/dist/manifest.d.ts.map +1 -0
  37. package/dist/manifest.js +40 -0
  38. package/dist/manifest.js.map +1 -0
  39. package/dist/migrations/20260425T161557_languages_currencies_init.d.ts +13 -0
  40. package/dist/migrations/20260425T161557_languages_currencies_init.d.ts.map +1 -0
  41. package/dist/migrations/20260425T161557_languages_currencies_init.js +60 -0
  42. package/dist/migrations/20260425T161557_languages_currencies_init.js.map +1 -0
  43. package/dist/migrations/index.d.ts +27 -0
  44. package/dist/migrations/index.d.ts.map +1 -0
  45. package/dist/migrations/index.js +27 -0
  46. package/dist/migrations/index.js.map +1 -0
  47. package/docs/languages.md +88 -0
  48. package/package.json +68 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * LocaleService — the small chunk of behaviour FR-105 calls
3
+ * "translation-fallback middleware".
4
+ *
5
+ * Two responsibilities:
6
+ * 1. `pickLocalizedValue(record, requestedLocale, defaultLocale?)` — given a
7
+ * `Record<localeCode, string>` (the multilingual JSONB shape used by
8
+ * Product / Category / CMS), return the best available string. Order:
9
+ * requested → default → first present → empty string.
10
+ * 2. `resolveRequestLocale(acceptLanguageHeader)` — pick a supported
11
+ * locale for the current request, with the admin-configured default
12
+ * as the last-resort fallback.
13
+ *
14
+ * The default locale is read once per resolver and cached for the lifetime
15
+ * of the service instance to avoid hitting the DB on every request; it is
16
+ * refreshed on `invalidateDefault()` after admin mutations.
17
+ */
18
+ export class LocaleService {
19
+ languageService;
20
+ cachedDefaultCode = null;
21
+ cachedDefaultLoadedAt = 0;
22
+ cacheTtlMs = 60_000;
23
+ constructor(languageService) {
24
+ this.languageService = languageService;
25
+ }
26
+ pickLocalizedValue(record, requestedLocale, defaultLocale) {
27
+ if (!record)
28
+ return '';
29
+ if (record[requestedLocale])
30
+ return record[requestedLocale];
31
+ if (defaultLocale && record[defaultLocale])
32
+ return record[defaultLocale];
33
+ const values = Object.values(record);
34
+ return values[0] ?? '';
35
+ }
36
+ async getDefaultLocale() {
37
+ const now = Date.now();
38
+ if (this.cachedDefaultCode && now - this.cachedDefaultLoadedAt < this.cacheTtlMs) {
39
+ return this.cachedDefaultCode;
40
+ }
41
+ const row = await this.languageService.getDefault();
42
+ this.cachedDefaultCode = row?.code ?? 'en-US';
43
+ this.cachedDefaultLoadedAt = now;
44
+ return this.cachedDefaultCode;
45
+ }
46
+ invalidateDefault() {
47
+ this.cachedDefaultLoadedAt = 0;
48
+ this.cachedDefaultCode = null;
49
+ }
50
+ /**
51
+ * Resolve a single locale string from an Accept-Language header against
52
+ * the active language pool. Returns the configured default when nothing
53
+ * matches.
54
+ */
55
+ async resolveRequestLocale(acceptLanguageHeader, activeLocales) {
56
+ const def = await this.getDefaultLocale();
57
+ if (!acceptLanguageHeader)
58
+ return def;
59
+ const candidates = parseAcceptLanguage(acceptLanguageHeader);
60
+ for (const cand of candidates) {
61
+ const exact = activeLocales.find((l) => l.toLowerCase() === cand.toLowerCase());
62
+ if (exact)
63
+ return exact;
64
+ // Fall back to the language part only ("en" matches "en-US").
65
+ const langOnly = cand.split('-')[0].toLowerCase();
66
+ const broad = activeLocales.find((l) => l.split('-')[0].toLowerCase() === langOnly);
67
+ if (broad)
68
+ return broad;
69
+ }
70
+ return def;
71
+ }
72
+ }
73
+ function parseAcceptLanguage(header) {
74
+ return header
75
+ .split(',')
76
+ .map((part) => {
77
+ const [tag, ...params] = part.trim().split(';');
78
+ const q = params
79
+ .map((p) => p.trim())
80
+ .find((p) => p.startsWith('q='))
81
+ ?.slice(2);
82
+ return { tag: tag.trim(), q: q ? Number(q) : 1 };
83
+ })
84
+ .filter((c) => c.tag && !Number.isNaN(c.q))
85
+ .sort((a, b) => b.q - a.q)
86
+ .map((c) => c.tag);
87
+ }
88
+ //# sourceMappingURL=locale-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale-service.js","sourceRoot":"","sources":["../../../src/backend/services/locale-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Languages module — manifest backfill (Module Lifecycle, feature 018).
3
+ *
4
+ * Predates the lifecycle system; this manifest is the static record
5
+ * required so the module participates in the registry. No install /
6
+ * uninstall hook today — the module's schema is owned by earlier
7
+ * platform-wide migrations.
8
+ */
9
+ export declare const manifest: {
10
+ id: string;
11
+ name: string;
12
+ version: string;
13
+ dependencies: string[];
14
+ description?: string | undefined;
15
+ acknowledgedDependencies?: {
16
+ moduleId: string;
17
+ port: string;
18
+ reason: string;
19
+ }[] | undefined;
20
+ nonBindingDependencies?: {
21
+ moduleId: string;
22
+ name: string;
23
+ kind: "contributes-to" | "degrades-without" | "refuses-without";
24
+ reason: string;
25
+ whenAbsent?: string | undefined;
26
+ }[] | undefined;
27
+ activation?: {
28
+ settingCode: string;
29
+ default: boolean;
30
+ } | {
31
+ nonDeactivatable: true;
32
+ reason: string;
33
+ } | undefined;
34
+ settings?: {
35
+ moduleCode: string;
36
+ groups: {
37
+ code: string;
38
+ name: string;
39
+ salesChannelCodes?: string[] | undefined;
40
+ isSystemProtected?: boolean | undefined;
41
+ }[];
42
+ settings: {
43
+ code: string;
44
+ name: string;
45
+ valueType: "string" | "number" | "boolean" | "json" | "string_list" | "secret" | "credential_ref";
46
+ defaultValue: unknown;
47
+ description?: string | undefined;
48
+ groupCode?: string | undefined;
49
+ previousDefaultValues?: unknown[] | undefined;
50
+ salesChannelCodes?: string[] | undefined;
51
+ enumOptions?: string[] | undefined;
52
+ configurationType?: string | undefined;
53
+ hidden?: boolean | undefined;
54
+ }[];
55
+ } | undefined;
56
+ i18n?: {
57
+ bundlesDir: string;
58
+ } | undefined;
59
+ docs?: false | {
60
+ dir: string;
61
+ } | undefined;
62
+ demo?: false | {
63
+ summary: string;
64
+ seed: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoSeedResult>;
65
+ reset: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoResetResult>;
66
+ after?: readonly string[] | undefined;
67
+ package?: string | undefined;
68
+ } | undefined;
69
+ actions?: {
70
+ id: string;
71
+ labelKey: string;
72
+ icon: "Plus" | "Sparkles" | "Settings" | "Search" | "Boxes" | "Layers" | "Menu" | "PlusCircle" | "PlusSquare" | "FilePlus" | "FolderPlus" | "Upload" | "FileUp" | "CloudUpload" | "Download" | "FileDown" | "FileText" | "BookOpen" | "Rss" | "Package" | "Tag" | "ShoppingCart" | "Receipt" | "CreditCard" | "Users" | "UserPlus" | "Inbox" | "ListChecks" | "ClipboardList" | "Image" | "Video" | "LayoutDashboard" | "PanelLeft" | "KeyRound" | "ShieldCheck" | "Edit" | "Archive" | "Box" | "Truck" | "CircleDollarSign" | "Activity" | "LineChart" | "Smartphone" | "Webhook" | "Scale" | "PlugZap" | "PercentDiamond" | "Newspaper" | "Languages" | "Eraser" | "Warehouse" | "TrendingDown" | "Bell" | "PackageOpen" | "Building2" | "Store" | "ClipboardCheck";
73
+ targetRoute: string;
74
+ keywords: string[];
75
+ weight: number;
76
+ descriptionKey?: string | undefined;
77
+ requiredPermission?: string | undefined;
78
+ }[] | undefined;
79
+ permissions?: {
80
+ code: string;
81
+ label: string;
82
+ module?: string | undefined;
83
+ description?: string | undefined;
84
+ requires?: string[] | undefined;
85
+ }[] | undefined;
86
+ transactionalEmails?: {
87
+ code: string;
88
+ name: string;
89
+ variables: {
90
+ key: string;
91
+ label: string;
92
+ sampleValue?: string | undefined;
93
+ description?: string | undefined;
94
+ }[];
95
+ description?: string | undefined;
96
+ group?: string | undefined;
97
+ }[] | undefined;
98
+ capabilities?: string[] | undefined;
99
+ exclusiveCapabilities?: {
100
+ key: string;
101
+ errorCode: string;
102
+ }[] | undefined;
103
+ errorCodes?: {
104
+ code: string;
105
+ tokens?: string[] | undefined;
106
+ }[] | undefined;
107
+ blocks?: {
108
+ name: string;
109
+ labelKey: string;
110
+ category: string;
111
+ contexts: ("invoice" | "email" | "cms" | "newsletter")[];
112
+ fields: Record<string, {
113
+ type: "number" | "object" | "array" | "text" | "textarea" | "select" | "radio" | "external" | "uuid" | "richtext";
114
+ label?: string | undefined;
115
+ required?: boolean | undefined;
116
+ options?: {
117
+ label: string;
118
+ value: string | number;
119
+ }[] | undefined;
120
+ refKind?: string | undefined;
121
+ }>;
122
+ descriptionKey?: string | undefined;
123
+ defaultProps?: Record<string, unknown> | undefined;
124
+ responsiveFields?: string[] | undefined;
125
+ previewIcon?: string | undefined;
126
+ weight?: number | undefined;
127
+ }[] | undefined;
128
+ blockCategories?: {
129
+ key: string;
130
+ titleKey: string;
131
+ contexts: ("invoice" | "email" | "cms" | "newsletter")[];
132
+ weight?: number | undefined;
133
+ visible?: boolean | undefined;
134
+ }[] | undefined;
135
+ env?: {
136
+ name: string;
137
+ describes: {
138
+ en: string;
139
+ pl: string;
140
+ };
141
+ requirement: {
142
+ kind: "required";
143
+ } | {
144
+ kind: "requiredWhen";
145
+ input: string;
146
+ equals: string;
147
+ } | {
148
+ kind: "optional";
149
+ without: {
150
+ en: string;
151
+ pl: string;
152
+ };
153
+ };
154
+ secret: boolean;
155
+ generable: boolean;
156
+ owner: {
157
+ kind: "platform";
158
+ } | {
159
+ kind: "application";
160
+ application: "admin" | "backend" | "storefront";
161
+ } | {
162
+ kind: "module";
163
+ moduleId: string;
164
+ };
165
+ consumers: ("admin" | "backend" | "storefront")[];
166
+ addressOf: "admin" | "backend" | "storefront" | null;
167
+ }[] | undefined;
168
+ };
169
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BnB,CAAC"}
@@ -0,0 +1,40 @@
1
+ import { defineModuleManifest } from '@endora-commerce/contracts';
2
+ /**
3
+ * Languages module — manifest backfill (Module Lifecycle, feature 018).
4
+ *
5
+ * Predates the lifecycle system; this manifest is the static record
6
+ * required so the module participates in the registry. No install /
7
+ * uninstall hook today — the module's schema is owned by earlier
8
+ * platform-wide migrations.
9
+ */
10
+ export const manifest = defineModuleManifest({
11
+ id: 'languages',
12
+ docs: { dir: 'docs' },
13
+ name: 'Languages',
14
+ description: 'Storefront/admin language catalog and per-channel locale routing.',
15
+ version: '1.0.0',
16
+ // `auth` owns the `requireAdmin` port the admin routes are gated by;
17
+ // `currencies` owns `currencyReadPort`. Feature 072 made both container
18
+ // resolutions.
19
+ //
20
+ // The `currencies` edge narrowed on 2026-08-29 and did not go away: the four
21
+ // `/api/v1/admin/currencies*` routes this module served, and the
22
+ // `currencyAdminPort` behind them, moved to their owner. What still reaches
23
+ // across is `GET /api/v1/i18n/config`, which answers with both catalogues and
24
+ // both defaults in one public payload — composition rather than ownership,
25
+ // and one read rather than a write surface.
26
+ dependencies: ['auth', 'currencies'],
27
+ // Feature 074 (Constitution XVII), test C3 — platform primitive. The flag
28
+ // used to rest on a two-hop walk of somebody else's `dependencies`
29
+ // (`organizations` → `dictionaries` → here); ruling 2 removes that as a
30
+ // ground entirely. This module's own is that it owns the locale scope: every
31
+ // localized surface, every translation bundle and every per-channel routing
32
+ // decision resolves against the language catalogue, so its absence is not a
33
+ // reduced platform but an unresolvable one.
34
+ activation: {
35
+ nonDeactivatable: true,
36
+ reason: 'The locale scope every localized surface and every translation bundle resolves ' +
37
+ 'against.',
38
+ },
39
+ });
40
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AAElE;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,oBAAoB,CAAC;IAC3C,EAAE,EAAE,WAAW;IACf,IAAI,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE;IACrB,IAAI,EAAE,WAAW;IACjB,WAAW,EACT,mEAAmE;IACrE,OAAO,EAAE,OAAO;IAChB,qEAAqE;IACrE,wEAAwE;IACxE,eAAe;IACf,EAAE;IACF,6EAA6E;IAC7E,iEAAiE;IACjE,4EAA4E;IAC5E,8EAA8E;IAC9E,2EAA2E;IAC3E,4CAA4C;IAC5C,YAAY,EAAE,CAAC,MAAM,EAAE,YAAY,CAAC;IACpC,0EAA0E;IAC1E,mEAAmE;IACnE,wEAAwE;IACxE,6EAA6E;IAC7E,4EAA4E;IAC5E,4EAA4E;IAC5E,4CAA4C;IAC5C,UAAU,EAAE;QACV,gBAAgB,EAAE,IAAI;QACtB,MAAM,EACJ,iFAAiF;YACjF,UAAU;KACb;CACF,CAAC,CAAC"}
@@ -0,0 +1,13 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * Languages + currencies init (T238 / FR-105). Two configuration tables
4
+ * with a partial unique index pinning at most one default row each.
5
+ *
6
+ * Co-located in the `languages` module folder to keep the migration
7
+ * sequence numerical without inventing a third placeholder module.
8
+ */
9
+ export declare class Migration20260425T161557LanguagesCurrenciesInit extends Migration {
10
+ up(): Promise<void>;
11
+ down(): Promise<void>;
12
+ }
13
+ //# sourceMappingURL=20260425T161557_languages_currencies_init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260425T161557_languages_currencies_init.d.ts","sourceRoot":"","sources":["../../src/migrations/20260425T161557_languages_currencies_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;GAMG;AACH,qBAAa,+CAAgD,SAAQ,SAAS;IAC7D,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IAwDnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAIrC"}
@@ -0,0 +1,60 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+ /**
3
+ * Languages + currencies init (T238 / FR-105). Two configuration tables
4
+ * with a partial unique index pinning at most one default row each.
5
+ *
6
+ * Co-located in the `languages` module folder to keep the migration
7
+ * sequence numerical without inventing a third placeholder module.
8
+ */
9
+ export class Migration20260425T161557LanguagesCurrenciesInit extends Migration {
10
+ async up() {
11
+ this.addSql(`
12
+ create table "languages" (
13
+ "code" varchar(12) not null,
14
+ "label" varchar(64) not null,
15
+ "is_default" boolean not null default false,
16
+ "is_active" boolean not null default true,
17
+ "sort_order" int not null default 0,
18
+ "created_at" timestamptz not null,
19
+ "updated_at" timestamptz not null,
20
+ constraint "languages_pkey" primary key ("code")
21
+ );
22
+ `);
23
+ this.addSql('create unique index "uniq_languages_one_default" on "languages" ("is_default") where "is_default" = true;');
24
+ this.addSql('create index "languages_is_active_index" on "languages" ("is_active");');
25
+ this.addSql(`
26
+ create table "currencies" (
27
+ "code" varchar(3) not null,
28
+ "label" varchar(64) not null,
29
+ "symbol" varchar(8) not null,
30
+ "is_default" boolean not null default false,
31
+ "is_active" boolean not null default true,
32
+ "sort_order" int not null default 0,
33
+ "created_at" timestamptz not null,
34
+ "updated_at" timestamptz not null,
35
+ constraint "currencies_pkey" primary key ("code")
36
+ );
37
+ `);
38
+ this.addSql('create unique index "uniq_currencies_one_default" on "currencies" ("is_default") where "is_default" = true;');
39
+ this.addSql('create index "currencies_is_active_index" on "currencies" ("is_active");');
40
+ // Bootstrap with the project defaults so quickstart works without an
41
+ // additional admin step. en-US + PLN match the existing seed data.
42
+ // Customer-facing label / symbol values use Postgres U&'…' Unicode
43
+ // literals so the source file stays ASCII-only (Principle VIII's
44
+ // engineering-artifact constraint), while the runtime row reflects
45
+ // exactly what the storefront should render.
46
+ this.addSql(`insert into "languages" ("code", "label", "is_default", "is_active", "sort_order", "created_at", "updated_at")
47
+ values
48
+ ('en-US', 'English (US)', true, true, 0, now(), now()),
49
+ ('pl-PL', 'Polski', false, true, 1, now(), now());`);
50
+ this.addSql(`insert into "currencies" ("code", "label", "symbol", "is_default", "is_active", "sort_order", "created_at", "updated_at")
51
+ values
52
+ ('PLN', 'Polish zloty', U&'z\\0142', true, true, 0, now(), now()),
53
+ ('EUR', 'Euro', U&'\\20AC', false, true, 1, now(), now());`);
54
+ }
55
+ async down() {
56
+ this.addSql('drop table if exists "currencies" cascade;');
57
+ this.addSql('drop table if exists "languages" cascade;');
58
+ }
59
+ }
60
+ //# sourceMappingURL=20260425T161557_languages_currencies_init.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"20260425T161557_languages_currencies_init.js","sourceRoot":"","sources":["../../src/migrations/20260425T161557_languages_currencies_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;GAMG;AACH,MAAM,OAAO,+CAAgD,SAAQ,SAAS;IACnE,KAAK,CAAC,EAAE;QACf,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;KAWX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT,2GAA2G,CAC5G,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,wEAAwE,CAAC,CAAC;QAEtF,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;KAYX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CACT,6GAA6G,CAC9G,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,0EAA0E,CAAC,CAAC;QAExF,qEAAqE;QACrE,mEAAmE;QACnE,mEAAmE;QACnE,iEAAiE;QACjE,mEAAmE;QACnE,6CAA6C;QAC7C,IAAI,CAAC,MAAM,CACT;;;4DAGsD,CACvD,CAAC;QACF,IAAI,CAAC,MAAM,CACT;;;oEAG8D,CAC/D,CAAC;IACJ,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,4CAA4C,CAAC,CAAC;QAC1D,IAAI,CAAC,MAAM,CAAC,2CAA2C,CAAC,CAAC;IAC3D,CAAC;CACF"}
@@ -0,0 +1,27 @@
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.** It creates the `languages` table and the `currencies` table
10
+ * together — the two shipped as one migration long before either module was a
11
+ * package — so `currencies` owns rows this file creates and ships no migration
12
+ * of its own. That is a fact about history, not an ordering rule: a timestamp
13
+ * orders this module's own migrations and nothing else (feature 081), and where
14
+ * this block sits relative to every other module's is decided by the manifest
15
+ * `dependencies` graph.
16
+ *
17
+ * The **named** export stays beside the array, and the asymmetry with
18
+ * `./backend` — which publishes an array and no named class (D-168) — is
19
+ * deliberate. `db/migrations-registry.generated.ts` imports the class by name
20
+ * from this specifier, and a migration class name is contract in a way an
21
+ * entity class name is not: `mikro_orm_migrations` persists it, so it is a
22
+ * string every already-migrated database holds.
23
+ */
24
+ import { Migration20260425T161557LanguagesCurrenciesInit } from './20260425T161557_languages_currencies_init.js';
25
+ export declare const migrations: (typeof Migration20260425T161557LanguagesCurrenciesInit)[];
26
+ export { Migration20260425T161557LanguagesCurrenciesInit };
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,+CAA+C,EAAE,MAAM,gDAAgD,CAAC;AAEjH,eAAO,MAAM,UAAU,4DAAoD,CAAC;AAE5E,OAAO,EAAE,+CAA+C,EAAE,CAAC"}
@@ -0,0 +1,27 @@
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.** It creates the `languages` table and the `currencies` table
10
+ * together — the two shipped as one migration long before either module was a
11
+ * package — so `currencies` owns rows this file creates and ships no migration
12
+ * of its own. That is a fact about history, not an ordering rule: a timestamp
13
+ * orders this module's own migrations and nothing else (feature 081), and where
14
+ * this block sits relative to every other module's is decided by the manifest
15
+ * `dependencies` graph.
16
+ *
17
+ * The **named** export stays beside the array, and the asymmetry with
18
+ * `./backend` — which publishes an array and no named class (D-168) — is
19
+ * deliberate. `db/migrations-registry.generated.ts` imports the class by name
20
+ * from this specifier, and a migration class name is contract in a way an
21
+ * entity class name is not: `mikro_orm_migrations` persists it, so it is a
22
+ * string every already-migrated database holds.
23
+ */
24
+ import { Migration20260425T161557LanguagesCurrenciesInit } from './20260425T161557_languages_currencies_init.js';
25
+ export const migrations = [Migration20260425T161557LanguagesCurrenciesInit];
26
+ export { Migration20260425T161557LanguagesCurrenciesInit };
27
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/migrations/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,+CAA+C,EAAE,MAAM,gDAAgD,CAAC;AAEjH,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,+CAA+C,CAAC,CAAC;AAE5E,OAAO,EAAE,+CAA+C,EAAE,CAAC"}
@@ -0,0 +1,88 @@
1
+ ---
2
+ title: languages
3
+ description: Pool of supported BCP-47 language tags + translation-fallback helper
4
+ ---
5
+
6
+ # `languages`
7
+
8
+ The installation-wide pool of supported BCP-47 language tags. Owns the
9
+ public `i18n/config` read path that storefront + admin consume to render
10
+ their language pickers, plus a small `LocaleService` that implements the
11
+ translation-fallback rule.
12
+
13
+ ## Public surface
14
+
15
+ | Verb + Path | Audience | Purpose |
16
+ | --- | --- | --- |
17
+ | `GET /api/v1/i18n/config` | storefront / admin | Active languages + currencies + the configured defaults |
18
+ | `GET /api/v1/admin/languages` | admin | Full list, including inactive rows |
19
+ | `PUT /api/v1/admin/languages/:code` | admin | Upsert |
20
+ | `POST /api/v1/admin/languages/:code/default` | admin | Promote to default (atomically demotes the prior default) |
21
+ | `DELETE /api/v1/admin/languages/:code` | admin | Remove (rejected for the default) |
22
+
23
+ The currency catalogue has the same shape under `/api/v1/admin/currencies`, and
24
+ those routes are **`currencies`'** — see [currencies](./currencies.md). They were
25
+ registered here until 2026-08-29, on `catalog:write`, serving another module's
26
+ table for no caller in this repository. What this module still composes is
27
+ `GET /api/v1/i18n/config`, which answers with both catalogues and both defaults
28
+ in one public payload and reads the currency half over `currencyReadPort`.
29
+
30
+ The four admin language routes above enforce `catalog:write`. That is a
31
+ neighbourhood claim of the same kind and has not been repaired.
32
+
33
+ ## Defaults
34
+
35
+ Exactly zero or one row in `languages` has `is_default = true`,
36
+ enforced by a partial unique index on `(is_default) WHERE is_default =
37
+ true`. Setting a new default runs the demote-then-promote pair inside one
38
+ MikroORM transaction so the partial unique index is never violated
39
+ mid-flight.
40
+
41
+ The `LanguageService.setDefault()` rejects rows where `isActive=false`
42
+ (`409 VALIDATION_FAILED`), and the `remove()` path refuses to delete a
43
+ row that is currently the default.
44
+
45
+ ## Bootstrap
46
+
47
+ Migration 012 inserts two rows so quickstart works without an admin step:
48
+ - `en-US` — default, active
49
+ - `pl-PL` — active
50
+
51
+ The customer-facing `label` and `symbol` (currencies) values are written
52
+ with Postgres `U&'…'` Unicode literals so the migration source file stays
53
+ ASCII-only (engineering artifacts stay English-only and ASCII-only;
54
+ the runtime row reflects what the storefront should render).
55
+
56
+ ## Translation-fallback (`LocaleService`)
57
+
58
+ `LocaleService.pickLocalizedValue(record, requestedLocale, defaultLocale?)`
59
+ implements the lookup chain:
60
+
61
+ 1. requested locale, if present in the record.
62
+ 2. configured default locale, if supplied and present.
63
+ 3. first present value in the record.
64
+ 4. empty string.
65
+
66
+ `resolveRequestLocale(acceptLanguageHeader, activeLocales)` parses an
67
+ `Accept-Language` header (q-weighted) and returns the highest-priority
68
+ match from the active language pool, with a language-only fallback so
69
+ `en-GB` matches `en-US`. Falls back to the configured default when nothing
70
+ matches.
71
+
72
+ The default-locale lookup is cached for 60 seconds; admin mutations call
73
+ `invalidateDefault()` so the cache flushes immediately after a change.
74
+
75
+ ## Entities
76
+
77
+ `Language` — natural primary key on the BCP-47 code; `label`,
78
+ `isDefault`, `isActive`, `sortOrder`.
79
+
80
+ ## Extension points
81
+
82
+ - **Per-Sales-Channel default** — when a Sales Channel ships its own
83
+ language, hook the resolver before
84
+ `LocaleService.resolveRequestLocale()` and use the channel's default
85
+ instead of the global one.
86
+ - **Translation pull/push** — emit a domain event when a localized field
87
+ changes and let an integration consume it for an external translation
88
+ workflow.
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@endora-commerce/mod-languages",
3
+ "version": "0.100.0",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "description": "Storefront/admin language catalog and per-channel locale routing.",
7
+ "license": "MIT",
8
+ "endora": {
9
+ "type": "module",
10
+ "id": "languages"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/endora-commerce/endora-commerce.git",
15
+ "directory": "packages/modules/languages"
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/manifest.d.ts",
23
+ "default": "./dist/manifest.js"
24
+ },
25
+ "./backend": {
26
+ "types": "./dist/backend/index.d.ts",
27
+ "default": "./dist/backend/index.js"
28
+ },
29
+ "./migrations": {
30
+ "types": "./dist/migrations/index.d.ts",
31
+ "default": "./dist/migrations/index.js"
32
+ },
33
+ "./package.json": "./package.json"
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "docs"
38
+ ],
39
+ "engines": {
40
+ "node": ">=22.18.0"
41
+ },
42
+ "peerDependencies": {
43
+ "@mikro-orm/core": "^6",
44
+ "@mikro-orm/migrations": "^6",
45
+ "@mikro-orm/postgresql": "^6",
46
+ "fastify": "^5",
47
+ "@endora-commerce/contracts": "0.100.0",
48
+ "@endora-commerce/platform": "0.100.0"
49
+ },
50
+ "devDependencies": {
51
+ "@fastify/type-provider-zod": "^1.0.0",
52
+ "@mikro-orm/core": "^6.6.13",
53
+ "@mikro-orm/migrations": "^6.6.13",
54
+ "@mikro-orm/postgresql": "^6.6.13",
55
+ "@types/node": "^22.9.0",
56
+ "fastify": "^5.12.5",
57
+ "typescript": "^5.9.3",
58
+ "vitest": "^4.1.11",
59
+ "@endora-commerce/contracts": "0.100.0",
60
+ "@endora-commerce/platform": "0.100.0"
61
+ },
62
+ "scripts": {
63
+ "build": "tsc -p tsconfig.build.json",
64
+ "typecheck": "tsc -p tsconfig.json",
65
+ "lint": "eslint src",
66
+ "test": "vitest run"
67
+ }
68
+ }