toga-ai 1.0.399 → 1.0.400

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.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-26
9
+ updated: 2026-07-21
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - _underscore/Model/Client/AssortmentTranslation.php
@@ -25,9 +25,15 @@ Serves Assortment (product-grouping) **names** in multiple languages by adding a
25
25
  English stays in `Assortments.name`; the sidecar holds only non-English overlays. Because the
26
26
  translation layer is fully metadata-driven (see
27
27
  [Language Translation Layer](../../api2/features/language-translation-layer.md)), wiring this up
28
- required **no api2 code change** — only a new table, model, Core record registration, ACL grant,
29
- and a single field link. Compass Canada's French (fr-CA) assortment names are seeded as the first
30
- consumer.
28
+ required only a new table, model, Core record registration, ACL grant, and a single field link.
29
+ Compass Canada's French (fr-CA) assortment names are seeded as the first consumer.
30
+
31
+ > **Correction (2026-07-21):** the original "no api2 code change needed" claim was only true for the
32
+ > **FK/top-level** read paths. Assortment names are surfaced on the storefront **category browse** via a
33
+ > `join=` request (`Assortments.name`), and the joined-select-fields read path did **not** consult the
34
+ > sidecar — it copied the raw SQL column straight into the response, so the seeded French names stayed
35
+ > English there. That required a real api2 change (the 5th serialization site) — see the
36
+ > [Language Translation Layer](../../api2/features/language-translation-layer.md) 2026-07-21 change history.
31
37
 
32
38
  ## Key files / entry points
33
39
 
@@ -51,8 +57,10 @@ The mechanism itself is the data-driven translation layer documented in
51
57
  4. **The field link (what makes it translate automatically).** The translatable `Assortments.name`
52
58
  RecordField gets its `Core.RecordFields.translationRecordFieldId` set to the sidecar's `name`
53
59
  RecordField. The api2 V2 layer reads `translationRecordFieldId` for every field and automatically
54
- returns the sidecar value for the caller's resolved language (falling back to English when absent) —
55
- so assortment names now translate **without any api2 code change**.
60
+ returns the sidecar value for the caller's resolved language (falling back to English when absent).
61
+ This works with no api2 code change for FK/top-level reads; the storefront's `join=` (joined-select)
62
+ read path additionally required the 5th-serialization-site fix landed 2026-07-21 (see the correction
63
+ note above and the Language Translation Layer doc).
56
64
  5. **ACL.** A full ACL chain grants the **Base** role read/write on the new record. Because the
57
65
  record's `aclDatabase = 'CLIENT'`, the grant lives in each client DB — see
58
66
  [ACL Permission Chain](./acl-permission-chain.md).
@@ -75,6 +83,11 @@ The mechanism itself is the data-driven translation layer documented in
75
83
 
76
84
  ## Change history
77
85
 
86
+ - 2026-07-21 — Correction: the "no api2 code change needed" claim held only for FK/top-level reads. The
87
+ storefront category browse surfaces `Assortments.name` via a `join=` request, and the joined-select read
88
+ path in api2 did not consult the sidecar (raw column copied through), so seeded fr-CA names stayed English
89
+ there. Fixed by the 5th-serialization-site change in the Language Translation Layer (V2.php); no change to
90
+ this sidecar/model/metadata was required. (bala)
78
91
  - 2026-06-26 — Initial build: `AssortmentTranslations` sidecar table + `_Model_Client_AssortmentTranslation`
79
92
  model + `Core.Records` 332 registration + `translationRecordFieldId` link on `Assortments.name` + Base-role
80
93
  ACL chain. Reuses the existing metadata-driven translation layer, so no api2 code change was needed.
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-14
9
+ updated: 2026-07-21
10
10
  owners: ["jcardinal", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -110,8 +110,11 @@ RecordField. `buildLookups`/RecordFields-load resolves this into
110
110
  `lookupTranslatableFieldByRecordFieldId[sourceRecordFieldId] => {sidecarRecordId, sidecarField}`.
111
111
 
112
112
  **Read path (PHP `_Model::load()`, NOT a SQL JOIN — deliberate choice).** At every response
113
- serialization site (top-level full-model, custom-fields, FK child, both inherent-child paths)
114
- `getTranslatedFieldValue($record, $field, $sourceModel, $defaultValue)` is called. When a non-base
113
+ serialization site `getTranslatedFieldValue($record, $field, $sourceModel, $defaultValue)` is called.
114
+ There are now **five** serialization sites, all of which must route through the helper: (1) top-level
115
+ full-model fields, (2) custom-field options, (3) foreign-key child objects, (4) inherent-child arrays
116
+ (both the specific-fields and the all-fields/expand-everything branches), and (5) **joined-select
117
+ fields** (fields requested from a `join=` table, e.g. `Assortments.name`). When a non-base
115
118
  language is active and the field is translatable, it loads the sidecar row for that source row +
116
119
  `languageId` (cached per row so multiple fields = one load) and returns the sidecar value if non-null;
117
120
  otherwise it returns the English default and queues a fallback warning. The warning now carries the
@@ -119,6 +122,21 @@ missing record's `uuid` in its `identifiers` and dedupes per-field-**and**-per-r
119
122
  (key `recordFieldId:uuid`) rather than once-per-field — so every record lacking a translation for a
120
123
  field is reported individually.
121
124
 
125
+ **Joined-select fields (site 5) — how the source row is reached.** A `join=` field's value arrives as
126
+ a flat SQL column (`$row->{Alias_field}`), so the helper needs the joined row's identity to load its
127
+ sidecar. In the LIST/GET branch of `processRoutePairs` (joined-select SQL build ~L3700-3800,
128
+ serialization ~L4038-4060): when `shouldTranslate()` is true and a selected joined field is translatable
129
+ (`lookupRecordFieldByRecordIdAndField` + `lookupTranslatableFieldByRecordFieldId`), the SQL build also
130
+ SELECTs that joined table's primary key (`` `Alias`.`id` AS `Alias_id` ``), tracked in
131
+ `$joinedTableRecordsByAlias` and `$joinedTablePrimaryKeyAliasByAlias`; the hidden PK column is only
132
+ appended when not already requested (`in_array` guard). At serialization the joined source model is
133
+ instantiated by that PK (`new $joinedSourceModelName((int)$row->$alias)`) and each joined value routes
134
+ through `getTranslatedFieldValue()`. English and API-credential/public traffic is byte-identical (all
135
+ new logic gated behind `shouldTranslate()`, which is false for en / API / public — no extra SQL column,
136
+ no extra model load, output unchanged). This is why a metadata-only sidecar (e.g. `AssortmentTranslations`)
137
+ still needed an api2 code change to translate on the `join=` read path even though FK/top-level reads
138
+ worked with no code change.
139
+
122
140
  **Write path.** On create and update, `extractTranslationWrites()` pulls translatable fields out of
123
141
  the write set for a non-base language (so the English source is never overwritten), and after the
124
142
  source row saves, `saveTranslationWrites()` upserts them into the sidecar for the current language.
@@ -191,6 +209,25 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
191
209
  (matching `ItemTranslations`) while the source fields are `utf8mb4_0900_ai_ci`. Ad-hoc queries that
192
210
  compare a sidecar string against a source string must add `COLLATE utf8mb4_bin` or MySQL throws a
193
211
  collation-mismatch error.
212
+ - **GROUP BY gotcha on translatable joined fields (site 5).** The sibling joined-field code pushes each
213
+ selected joined field into `$groupBy` under aggregates; the hidden PK column added for translation must
214
+ **also** be pushed to `$groupBy` when `!empty($aggregateFunctionFields)`, or a non-English list request
215
+ combining a translatable `join=` with an aggregate/`group=` throws MySQL 1055 under `ONLY_FULL_GROUP_BY`.
216
+ Mirror the sibling `$groupBy[]` push for the PK column.
217
+ - **DISTINCT granularity (site 5).** The list path always runs `SELECT DISTINCT`, so adding the hidden PK
218
+ to the SELECT can in theory change DISTINCT granularity. Only reachable with distinct + join + a
219
+ translatable joined field whose rows share values but differ by PK; verified harmless for the real
220
+ storefront query (a `WHERE` pinning one assortment yields the same 4 rows). Worth checking if a
221
+ translatable join is added to a query that relies on DISTINCT collapsing duplicate rows.
222
+ - **Per-row cost on translatable-join lists (site 5).** `getTranslatedFieldValue` dereferences the source
223
+ model key via `_Model __get`, forcing a full row load, so ~2 queries per row (source + sidecar) for
224
+ non-English translatable-join lists — consistent with the feature's per-row-load tradeoff. Candidate
225
+ future optimization: let the helper accept a known id instead of a live `_Model`.
226
+ - **Pre-existing (not introduced by site 5): `join=`/`ojoin=` identifiers are unvalidated.** Table/alias/
227
+ ON-clause identifiers flow from the query string into SQL with no validation/escaping (`V2.php` ~L3416-3463,
228
+ `parseOptionsJoin` ~L7874-7920). The new PK-select line reuses the same already-tainted `$tableAlias` as the
229
+ adjacent pre-existing lines, so it adds no new injection class — but the underlying tainted-identifier path
230
+ is a standing concern flagged for separate follow-up.
194
231
  - Reading a possibly-absent magic field off a generic `_Model` needs `array_key_exists(...)` +
195
232
  `__get` — `isset()`/`?? null` always read false/null. See
196
233
  [_Model magic-field access](../../_underscore/features/model-magic-field-access.md); this was the
@@ -202,6 +239,20 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
202
239
 
203
240
  ## Change history
204
241
 
242
+ - 2026-07-21 — Wired translation into the **joined-select-fields** path (the 5th and final read-path
243
+ serialization site) in `V2.php` `processRoutePairs` (joined-select SQL build ~L3700-3800, serialization
244
+ ~L4038-4060). `join=` fields (e.g. `Assortments.name`) previously copied the raw SQL column straight into
245
+ the response and never consulted the sidecar, so Compass Canada's already-seeded French assortment/category
246
+ names stayed English on the storefront category browse. Fix (gated behind `shouldTranslate()`, so en/API/
247
+ public output is byte-identical): SELECT the joined table's PK as a hidden `` `Alias`.`id` AS `Alias_id` ``
248
+ (tracked in `$joinedTableRecordsByAlias`/`$joinedTablePrimaryKeyAliasByAlias`, `in_array`-guarded), then at
249
+ serialization instantiate the joined source model by that PK and route the value through
250
+ `getTranslatedFieldValue()`. Also fixed a GROUP BY 1055 bug (the hidden PK must be pushed to `$groupBy` when
251
+ aggregates are present). Noted the DISTINCT-granularity edge, the ~2-queries-per-row cost, and that the
252
+ pre-existing unvalidated `join=` identifier path is unchanged (no new injection class). No toga2-commerce or
253
+ DB/metadata change needed — sidecar rows, the `Assortments.name` `translationRecordFieldId` link, and the
254
+ fr-CA storefront request all already existed. cso: SAFE TO SHIP; php-reviewer: safe after the GROUP BY fix
255
+ (applied), remaining items non-blocking follow-ups. (bala)
205
256
  - 2026-07-13 — Extended the translation layer to item **feature** text: three new sidecar tables
206
257
  (`FeatureTranslations`, `ItemCategoryFeatureGroupTranslations`, `ItemFeatureTranslations`; Records
207
258
  343/344/345, RecordFields 2442–2457) with `translationRecordFieldId` wired to the source fields,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.399",
3
+ "version": "1.0.400",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",