toga-ai 1.0.194 → 1.0.196
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.
- package/knowledge/2.0/apps/_underscore/INDEX.md +2 -0
- package/knowledge/2.0/apps/_underscore/features/forecast-sale-import.md +157 -0
- package/knowledge/2.0/apps/_underscore/features/model-magic-field-access.md +57 -0
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/language-translation-layer.md +48 -2
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
|
|
7
7
|
| [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
|
|
8
8
|
| [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
|
|
9
|
+
| [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | _underscore/Component/Forecast/SaleImport/SaleImport.php, _underscore/Component/Forecast/Db/Db.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php |
|
|
10
|
+
| [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
|
|
9
11
|
| [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
|
|
10
12
|
| [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
|
|
11
13
|
| [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql |
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Forecast.Sales NetSuite import engine (real-time webhook)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-24
|
|
10
|
+
owners: [dfranks]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Component/Forecast/SaleImport/SaleImport.php
|
|
13
|
+
- _underscore/Component/Forecast/Db/Db.php
|
|
14
|
+
- worker2/Worker/Netsuite/Invoice.php
|
|
15
|
+
- worker2/Worker/Netsuite/CashSale.php
|
|
16
|
+
- worker2/Worker/Netsuite/CreditMemo.php
|
|
17
|
+
- worker2/Worker/Netsuite/CashRefund.php
|
|
18
|
+
- worker2/Worker/Netsuite/Opportunity.php
|
|
19
|
+
- worker2/Worker/Netsuite/SalesOrder.php
|
|
20
|
+
related:
|
|
21
|
+
- ../architecture.md
|
|
22
|
+
- ../../worker2/architecture.md
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Summary
|
|
26
|
+
Real-time importer that takes a NetSuite **sale** record and writes its lines into
|
|
27
|
+
`Forecast.Sales` (the Forecast2 revenue table). Covers the four NetSuite sale record
|
|
28
|
+
types: **invoice, cashSale, creditMemo, cashRefund**. It is the webhook-driven replacement
|
|
29
|
+
for the SALES section of the legacy 5-minute pull cron
|
|
30
|
+
(`worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php`), and mirrors the
|
|
31
|
+
already-shipped SalesOrder (open-orders) and Opportunity webhook handlers.
|
|
32
|
+
|
|
33
|
+
The shared engine is `_Component_Forecast_SaleImport`; four thin worker2 handlers
|
|
34
|
+
(`_Worker_Netsuite_{Invoice,CashSale,CreditMemo,CashRefund}`) just delegate to it.
|
|
35
|
+
|
|
36
|
+
**Critical, non-obvious facts (read before touching this):**
|
|
37
|
+
- **Sign convention differs from the cron** — the raw REST record is all-positive, so the
|
|
38
|
+
webhook applies the sign factor **uniformly to every line**, not just shipping. Copying
|
|
39
|
+
the cron's sign handling reintroduces the credit-memo sign bug. (See Sign convention.)
|
|
40
|
+
- The engine is a `_Component_*` class and therefore **must live in `_underscore`**, not in
|
|
41
|
+
worker2 — the autoloader rejects a `_Component_*` loaded from a project path. (See Gotchas.)
|
|
42
|
+
|
|
43
|
+
## Key files / entry points
|
|
44
|
+
| File | Role |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `_underscore/Component/Forecast/SaleImport/SaleImport.php` | the engine (shared body for all four types) |
|
|
47
|
+
| `_underscore/Component/Forecast/Db/Db.php` | shared NS-REST + sublist-pagination helpers |
|
|
48
|
+
| `worker2/Worker/Netsuite/Invoice.php` etc. | four thin handlers; `post`/`put` → `sync`, `delete` → `removeAll` |
|
|
49
|
+
|
|
50
|
+
Each handler is `abstract class _Worker_Netsuite_<Type> implements
|
|
51
|
+
_Interface_Static_Webhook_Netsuite`. The router (`_Worker_NetSuite::Webhook`) PascalCases
|
|
52
|
+
the inbound `recordType` into the class name; `invoice`/`cashSale`/`creditMemo`/`cashRefund`
|
|
53
|
+
map 1:1, so **no router change is needed** to add these.
|
|
54
|
+
|
|
55
|
+
Engine public surface: `sync(int $internalId, string $type)` and
|
|
56
|
+
`removeAll(int $internalId)`. post/put → `sync`; delete → `removeAll`.
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
Per-record flow in `sync`:
|
|
60
|
+
1. REST GET the record (`expandSubResources=true`) via `_Component_Forecast_Db::fetchRecord`.
|
|
61
|
+
2. **Per-type status gate** → if excluded, call `removeAll` and stop. Exclusions: invoice
|
|
62
|
+
`Voided`/`Rejected`; cashSale `Unapproved Payment`; creditMemo `Voided`; cashRefund has
|
|
63
|
+
no gate.
|
|
64
|
+
3. If `shippingCost != 0`, append a **synthetic SHIPPING line at lineNumber 0** using
|
|
65
|
+
NetSuite item internalId **13500** (a real `Forecast.Items` row).
|
|
66
|
+
4. Per line: resolve the item (with self-heal — see below); skip `itemGroup` items
|
|
67
|
+
(no line revenue).
|
|
68
|
+
5. Guarded upsert keyed on `(netsuiteTransactionInternalId, lineNumber)`.
|
|
69
|
+
6. Reconcile-delete any lines NetSuite no longer returns.
|
|
70
|
+
7. Commit `DB_FORECAST`.
|
|
71
|
+
|
|
72
|
+
`removeAll(internalId)` deletes all rows for that transaction (used by delete events and by
|
|
73
|
+
the status gate).
|
|
74
|
+
|
|
75
|
+
### Shared helpers on `_Component_Forecast_Db` (dedup)
|
|
76
|
+
- `fetchRecord(route, label)` — NS REST GET + decode.
|
|
77
|
+
- `collectItemLines(record)` + `nextPageRoute(links)` — `rel=next` sublist pagination.
|
|
78
|
+
|
|
79
|
+
`Opportunity.php` and `SalesOrder.php` were refactored onto these shared versions; their
|
|
80
|
+
private `fetchRecord`/`collectAllItemLines`/`nextPageRoute` copies (and SalesOrder's
|
|
81
|
+
orphaned `MAX_SUBLIST_PAGES` const) were deleted.
|
|
82
|
+
|
|
83
|
+
## Sign convention (the load-bearing decision)
|
|
84
|
+
The **raw REST record** returns each line `amount`/`costEstimate` **POSITIVE for all four
|
|
85
|
+
types** (verified by live probe 2026-06-24 against NS account 1095849: creditMemo lines
|
|
86
|
+
+350/+400/+700/…, cashRefund +885, invoice all positive). The engine therefore applies the
|
|
87
|
+
sign factor **uniformly**:
|
|
88
|
+
- `factor = -1` for **creditMemo + cashRefund**, `+1` for **invoice + cashSale**
|
|
89
|
+
- `revenue = amount * factor`
|
|
90
|
+
- `profit = (amount - costEstimate) * factor`
|
|
91
|
+
- shipping line `amount = shippingCost * factor`
|
|
92
|
+
|
|
93
|
+
This is the **opposite of the legacy cron**, which reads the pre-signed listSales 1.0 shim
|
|
94
|
+
(already `-foreignamount`) and so applies the factor **only** to the synthetic shipping
|
|
95
|
+
line. **Do not copy the cron's sign handling into the webhook** — it reintroduces the
|
|
96
|
+
~$470K credit-memo sign bug (credits stored positive). The cron is not authoritative for
|
|
97
|
+
the raw-REST path. Confirmed end-to-end against the local Forecast mirror (creditMemo
|
|
98
|
+
revenue stored -350..-600; cashRefund revenue -885 / profit -78; invoice positive).
|
|
99
|
+
|
|
100
|
+
> A reviewer may flag "profit double-signs the factor" — false positive:
|
|
101
|
+
> `(amount*factor) - (costEstimate*factor) == (amount - costEstimate)*factor`.
|
|
102
|
+
|
|
103
|
+
## Item self-heal
|
|
104
|
+
When a sale line references a NetSuite item not yet in `Forecast.Items`, the engine pulls it
|
|
105
|
+
on demand via `_Worker_Netsuite_Item::syncItem()` (the same method the open-orders handler
|
|
106
|
+
uses) and then writes the line — instead of the cron's silent skip. Failure semantics:
|
|
107
|
+
- `syncItem` throws **`RuntimeException`** for a genuinely unmappable item (no Forecast enum
|
|
108
|
+
value / absent from NetSuite) → engine catches it and **skips that one line** (logging it),
|
|
109
|
+
matching cron tolerance.
|
|
110
|
+
- A real infrastructure failure is raised by `_Component_Api_Netsuite::send` as a **plain
|
|
111
|
+
`Exception`** (superclass of `RuntimeException`) → **not caught** → propagates and fails
|
|
112
|
+
the job for retry.
|
|
113
|
+
- `itemGroup` items carry no line revenue and are skipped.
|
|
114
|
+
|
|
115
|
+
## Data model — Forecast.Sales schema facts
|
|
116
|
+
Prod `Forecast.Sales` writable columns: `netsuiteTransactionType`
|
|
117
|
+
(enum `'invoice','cashSale','creditMemo','cashRefund'`), `netsuiteTransactionInternalId`,
|
|
118
|
+
`tranDate`, `tranNumber`, `customerId`, `leadSource`, `salesRepEmployeeId`, `itemId`,
|
|
119
|
+
`lineNumber`, `revenue`/`profit` `decimal(14,2)`, `createdFromNetsuiteTransactionInternalId`.
|
|
120
|
+
- **No `amountDue` and no `dtPendingBilling` column** in prod (no staged dbchanges2 adds
|
|
121
|
+
them) → this importer does **not** write amountDue. The engine is structured so adding it
|
|
122
|
+
later is a one-line change (value map + tracked-columns + change-detection SELECT).
|
|
123
|
+
- **No `classificationId` column** (unlike OpenOrderItems) → classification is not resolved
|
|
124
|
+
or written for Sales.
|
|
125
|
+
- **No unique index** on `(netsuiteTransactionInternalId, lineNumber)`; the composite
|
|
126
|
+
`Sales_netsuiteTransactionType_IDX` leads with `netsuiteTransactionType`. Because there is
|
|
127
|
+
no unique key, inserts use a guarded `INSERT ... SELECT ... FROM DUAL WHERE NOT EXISTS(...)`
|
|
128
|
+
for idempotency. This reduces but does not fully eliminate concurrent-duplicate risk;
|
|
129
|
+
serialized-per-internalId + reconcile-delete close the gap. A real unique index is
|
|
130
|
+
**deliberately deferred**.
|
|
131
|
+
|
|
132
|
+
## Integration mechanism — the NetSuite AMQ enqueuer is per-record-type
|
|
133
|
+
The User Event enqueuer `customscript_ue_amq_enqueue`
|
|
134
|
+
(`test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js`) must be **deployed on
|
|
135
|
+
each record type** for that type's create/edit/delete to enqueue a webhook. Inbound sale
|
|
136
|
+
events therefore require this one script deployed on **Invoice, Cash Sale, Credit Memo, and
|
|
137
|
+
Cash Refund**. The drainer (`customscript_ss_amq_drain`) is record-type-agnostic and needs
|
|
138
|
+
no per-type deployment.
|
|
139
|
+
|
|
140
|
+
The enqueuer's `RECORD_TYPE_MAP` (lowercase NS `record.type` → camelCase `recordType` the
|
|
141
|
+
worker2 router PascalCases) already maps the four sale types 1:1. Note: NetSuite emits item
|
|
142
|
+
events as specific **subtypes** (`inventoryItem`, `nonInventoryResaleItem`, `kitItem`,
|
|
143
|
+
`itemGroup`, …) with no generic `item` type — collapsing those subtype VALUES to `'item'`
|
|
144
|
+
would be needed only for a future real-time item webhook, **not** for this Sales importer
|
|
145
|
+
(which keeps items fresh via the hourly item pull cron plus the inline self-heal).
|
|
146
|
+
|
|
147
|
+
## Gotchas / known issues
|
|
148
|
+
- **`_Component_*` classes must live under the `_underscore` framework root, never a project
|
|
149
|
+
repo.** The autoloader rejects a `_Component_*`/`_Model_*` loaded from a project path
|
|
150
|
+
(e.g. `./` under worker2) with "namespace … has not been defined" — at **class-load
|
|
151
|
+
(runtime), not `php -l`**. So this engine lives in `_underscore` next to
|
|
152
|
+
`_Component_Forecast_Db` even though only worker2 uses it; it references the worker2 class
|
|
153
|
+
`_Worker_Netsuite_Item`, resolved lazily at runtime in the worker2 context.
|
|
154
|
+
- The cron's sign handling is not portable here — see Sign convention.
|
|
155
|
+
|
|
156
|
+
## Change history
|
|
157
|
+
- 2026-06-24 — Built the Forecast.Sales real-time webhook importer (shared `_Component_Forecast_SaleImport` engine + four thin worker2 handlers), replacing the legacy cron SALES section; decided the uniform-factor sign convention against the raw REST record; deduped `fetchRecord`/sublist pagination onto `_Component_Forecast_Db`. (dfranks)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: _Model magic-field access (__get without __isset)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-25
|
|
10
|
+
owners: ["jcardinal"]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Core/Model.php
|
|
13
|
+
related:
|
|
14
|
+
- ../../api2/features/language-translation-layer.md
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Summary
|
|
18
|
+
|
|
19
|
+
`_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`.
|
|
20
|
+
Because PHP does not route `isset()` / null-coalescing through `__get()`, any `isset($model->field)` or
|
|
21
|
+
`$model->field ?? null` on a magic (DB) field is **always** `false` / `null` — even when the column
|
|
22
|
+
exists and has a value. And a bare `$model->field` read for a column that is **not** on the model
|
|
23
|
+
**throws**. This bites any code that probes for an optional/conditional field on a generic model.
|
|
24
|
+
|
|
25
|
+
## How it works
|
|
26
|
+
|
|
27
|
+
- `_Model::__get($name)` resolves a configured DB field and returns its value; for an unconfigured
|
|
28
|
+
field it throws (not a silent null).
|
|
29
|
+
- There is **no** `_Model::__isset()`, so PHP's `isset()`/`empty()`/`??` short-circuit to "not set"
|
|
30
|
+
for every magic field before `__get` is ever consulted. This is a PHP language rule, not a bug in
|
|
31
|
+
the value.
|
|
32
|
+
|
|
33
|
+
**Safe pattern for a possibly-absent field on a generic model:**
|
|
34
|
+
|
|
35
|
+
```php
|
|
36
|
+
if (array_key_exists('uuid', $model->getFieldConfig())) {
|
|
37
|
+
$uuid = $model->uuid; // safe: __get won't throw, field is known to exist
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Check `array_key_exists('field', $model->getFieldConfig())` first, then read via `__get`. Do **not**
|
|
42
|
+
gate on `isset($model->field)` or `$model->field ?? $default` — both silently misbehave.
|
|
43
|
+
|
|
44
|
+
## Gotchas / known issues
|
|
45
|
+
|
|
46
|
+
- `isset($model->magicField)` / `$model->magicField ?? null` are **always** false/null for DB-backed
|
|
47
|
+
magic fields. Use `getFieldConfig()` + `array_key_exists` to test presence.
|
|
48
|
+
- A bare `__get` on an unconfigured field **throws** — never read a maybe-absent field without the
|
|
49
|
+
`getFieldConfig()` guard.
|
|
50
|
+
- This surfaced as a missing `uuid` in the API translation-layer fallback warning — see
|
|
51
|
+
[Language Translation Layer](../../api2/features/language-translation-layer.md).
|
|
52
|
+
|
|
53
|
+
## Change history
|
|
54
|
+
|
|
55
|
+
- 2026-06-25 — Documented the `__get`-without-`__isset` gap and the `getFieldConfig()` +
|
|
56
|
+
`array_key_exists` safe-read pattern, discovered while debugging a missing `uuid` in the API2
|
|
57
|
+
translation fallback warning. (jcardinal)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
|
|
6
|
-
| [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, expanding later) in multiple languages without forking the schema or breaking English consume | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql |
|
|
6
|
+
| [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, expanding later) in multiple languages without forking the schema or breaking English consume | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql |
|
|
7
7
|
| [POST + JSON-body args for scripted APIs](features/scripted-api-post-body-args.md) | The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted API can receive its arguments from the **JSON request body** instead | api2/Component/Api/V2/V2.php |
|
|
8
8
|
| [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
|
|
9
9
|
| [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | |
|
|
@@ -6,10 +6,11 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
9
|
+
updated: 2026-06-25
|
|
10
10
|
owners: ["jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
|
+
- api2/Component/Api/V2/Response/Response.php
|
|
13
14
|
- _underscore/Model/Core/Setting.php
|
|
14
15
|
- _underscore/Model/Core/RecordField.php
|
|
15
16
|
- _underscore/Model/Core/DefaultGlobalSetting.php
|
|
@@ -20,6 +21,7 @@ files:
|
|
|
20
21
|
- dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql
|
|
21
22
|
related:
|
|
22
23
|
- ./acl-permission-chain.md
|
|
24
|
+
- ../../_underscore/features/model-magic-field-access.md
|
|
23
25
|
---
|
|
24
26
|
|
|
25
27
|
## Summary
|
|
@@ -55,6 +57,19 @@ persona** (in the user's persona order) that sets it wins. The resolved code is
|
|
|
55
57
|
response as `audience.language`. API-credential auth gets no language. Token refresh copies the claim,
|
|
56
58
|
so a language change requires re-authentication.
|
|
57
59
|
|
|
60
|
+
**Invalid-language hard failure (auth time).** The resolved code is validated against the client's
|
|
61
|
+
`Languages` table. An **unset/empty** setting defaults to `en` (no error). But a **non-empty** code
|
|
62
|
+
that does **not** exist in `Languages` now **fails authentication** with HTTP 400
|
|
63
|
+
`EV-16 DEFINED_MESSAGE_ERROR_INVALID_LANGUAGE_SETTING` (validation group, defined in `Response.php`),
|
|
64
|
+
with the offending `language` code in the message `identifiers` — instead of silently falling back to
|
|
65
|
+
`en`. A misconfigured language must surface loudly, not silently degrade.
|
|
66
|
+
|
|
67
|
+
**Auth-mint status finalization (subtle).** The auth success path sets `status = 201` and attaches the
|
|
68
|
+
tokens. EV-16 is signalled by a local flag `$invalidLanguageSetting` set at the resolution site; the
|
|
69
|
+
finalization branches `if (empty($invalidLanguageSetting))` → `201` + tokens, else
|
|
70
|
+
`addDefinedMessage(EV-16)` + `data = null`. See Gotchas — do **not** convert this to an
|
|
71
|
+
`is_null($this->response->status)` guard.
|
|
72
|
+
|
|
58
73
|
**Which fields are translatable (metadata-driven).** `Core.RecordFields.translationRecordFieldId`
|
|
59
74
|
(new column, positioned after `recordId`) points a source field's RecordField at the sidecar field's
|
|
60
75
|
RecordField. `buildLookups`/RecordFields-load resolves this into
|
|
@@ -65,7 +80,10 @@ serialization site (top-level full-model, custom-fields, FK child, both inherent
|
|
|
65
80
|
`getTranslatedFieldValue($record, $field, $sourceModel, $defaultValue)` is called. When a non-base
|
|
66
81
|
language is active and the field is translatable, it loads the sidecar row for that source row +
|
|
67
82
|
`languageId` (cached per row so multiple fields = one load) and returns the sidecar value if non-null;
|
|
68
|
-
otherwise it returns the English default and queues a
|
|
83
|
+
otherwise it returns the English default and queues a fallback warning. The warning now carries the
|
|
84
|
+
missing record's `uuid` in its `identifiers` and dedupes per-field-**and**-per-record
|
|
85
|
+
(key `recordFieldId:uuid`) rather than once-per-field — so every record lacking a translation for a
|
|
86
|
+
field is reported individually.
|
|
69
87
|
|
|
70
88
|
**Write path.** On create and update, `extractTranslationWrites()` pulls translatable fields out of
|
|
71
89
|
the write set for a non-base language (so the English source is never overwritten), and after the
|
|
@@ -96,6 +114,26 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
|
|
|
96
114
|
per-field fallback warning signals the English fallback.
|
|
97
115
|
- The base language is `en`; when the resolved language is `en` (or API auth) all translation logic is
|
|
98
116
|
skipped and responses are byte-identical to pre-feature.
|
|
117
|
+
- **"Why isn't my language setting applying?"** A `ClientGlobalSettings` row with `isOverridable = 0`
|
|
118
|
+
**locks** the value against all downstream persona/user layers — a `UserGlobalSettings` (or persona)
|
|
119
|
+
override never wins while the client-global layer is locked. This is by design (the 8-layer cascade
|
|
120
|
+
with `isOverridable` locking). To let user/persona overrides apply, the locking layer must be
|
|
121
|
+
`isOverridable = 1` (or its value cleared). The cascade itself is correct — this is a data-config
|
|
122
|
+
gotcha, not a code bug.
|
|
123
|
+
- **Namespace gotcha in `V2.php` (`namespace api;`).** The api-namespaced classes
|
|
124
|
+
(`_Component_Api_V2_Response`, `_Component_Api_V2_Response_Message`) must be referenced **unqualified**
|
|
125
|
+
(resolves to `api\…`) or with the full `\api\_Component_Api_V2_Response_Message`. A bare leading
|
|
126
|
+
backslash (`\_Component_Api_V2_Response_Message`) forces the global namespace and throws
|
|
127
|
+
`EO-1 "Could not find required file"` via the autoloader. The translation fallback-warning code hit this.
|
|
128
|
+
- **Do NOT guard the auth-mint finalization with `is_null($this->response->status)`.** The auth response
|
|
129
|
+
auto-adds client `name` + user `firstName`/`lastName` and runs `processRoutePairs()`, which sets
|
|
130
|
+
`$this->response->status` to `200` **before** finalization. An `is_null` guard there suppresses the
|
|
131
|
+
`201` + tokens and yields a broken `200`/`data:null` login. Branch on the explicit
|
|
132
|
+
`$invalidLanguageSetting` flag instead.
|
|
133
|
+
- Reading a possibly-absent magic field off a generic `_Model` needs `array_key_exists(...)` +
|
|
134
|
+
`__get` — `isset()`/`?? null` always read false/null. See
|
|
135
|
+
[_Model magic-field access](../../_underscore/features/model-magic-field-access.md); this was the
|
|
136
|
+
cause of the uuid not appearing in the fallback warning.
|
|
99
137
|
- Migrations not yet executed at time of writing; needs live verification.
|
|
100
138
|
|
|
101
139
|
## Change history
|
|
@@ -103,6 +141,14 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
|
|
|
103
141
|
- 2026-06-23 — Initial build: audience.language + JWT embedding, ItemTranslations sidecar + metadata +
|
|
104
142
|
full ACL chain, and translation-aware read/write at the API layer. Also fixed a latent autoload bug
|
|
105
143
|
by renaming `DefaultFlobalSetting.php` → `DefaultGlobalSetting.php`. (jcardinal)
|
|
144
|
+
- 2026-06-25 — Added defined error `EV-16 INVALID_LANGUAGE_SETTING` (HTTP 400, in `Response.php`): a
|
|
145
|
+
non-empty language code absent from `Languages` now fails auth instead of silently falling back to
|
|
146
|
+
`en` (empty still defaults to `en`). Documented the auth-mint `201` finalization (branch on the
|
|
147
|
+
`$invalidLanguageSetting` flag, never an `is_null(status)` guard). Fixed the fallback-warning
|
|
148
|
+
namespace bug (no bare leading backslash on api-namespaced classes). Fallback warning now includes the
|
|
149
|
+
record `uuid` and dedupes per-record (`recordFieldId:uuid`). Documented the `isOverridable = 0`
|
|
150
|
+
client-global lock as a config gotcha. Discovered the `_Model` `__isset` gap behind the missing-uuid
|
|
151
|
+
bug (see linked _underscore doc). Live-tested on the local Compass DB. (jcardinal)
|
|
106
152
|
|
|
107
153
|
## Related docs
|
|
108
154
|
|
package/knowledge/INDEX.md
CHANGED
|
@@ -15,7 +15,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
15
15
|
|
|
16
16
|
## 2.0 framework
|
|
17
17
|
|
|
18
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
18
|
+
- **_underscore** (_Underscore) _(framework core)_ — 14 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
19
19
|
- **worker2** (Worker) — 14 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
20
20
|
- **api2** (API) — 6 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
21
21
|
- **dbchanges2** (Database Changes) _(framework core)_ — 2 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
package/package.json
CHANGED