toga-ai 1.0.581 → 1.0.583
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/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +69 -1
- package/knowledge/2.0/apps/api2/features/v2-rest-query-contract.md +35 -0
- package/knowledge/2.0/apps/dbchanges2/INDEX.md +2 -2
- package/knowledge/2.0/apps/dbchanges2/features/surface-layer-schema.md +25 -1
- package/knowledge/2.0/apps/dbchanges2/workflows/nonprod-metadata-drift-repair.md +19 -1
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +2 -0
- package/knowledge/2.0/apps/toga2-commerce/features/catalog-cache-freshness.md +112 -0
- package/knowledge/2.0/apps/toga2-commerce/features/inactive-item-purchase-gating.md +197 -0
- package/knowledge/2.0/apps/toga2-commerce/features/order-submit-sync-sequencing.md +45 -1
- package/knowledge/2.0/apps/toga25-supply/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga25-supply/features/record-modals-and-nested-tables.md +20 -1
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql |
|
|
6
|
+
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
|
|
7
7
|
| [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
|
|
8
8
|
| [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
|
|
9
9
|
| [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-14
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala", "snaredla"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -19,8 +19,12 @@ files:
|
|
|
19
19
|
- _underscore/Model/Compass/SalesOrderItem.php
|
|
20
20
|
- dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
|
|
21
21
|
- dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql
|
|
22
|
+
- dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
|
|
23
|
+
- dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
|
|
24
|
+
- dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
|
|
22
25
|
related:
|
|
23
26
|
- ./record-scripts.md
|
|
27
|
+
- ../../toga2-commerce/features/inactive-item-purchase-gating.md
|
|
24
28
|
- ../architecture.md
|
|
25
29
|
- ../../_underscore/features/acl-permission-chain.md
|
|
26
30
|
- ../../_underscore/features/recursive-item-fulfillments.md
|
|
@@ -137,6 +141,37 @@ Two consequences worth internalising:
|
|
|
137
141
|
silently does nothing — the same class of failure as the `internalApiRequest` `data`-envelope
|
|
138
142
|
trap below.
|
|
139
143
|
|
|
144
|
+
### A PRE interceptor on a PARENT record also fires on the NESTED child POST — with the CHILD as `$payload`
|
|
145
|
+
|
|
146
|
+
`POST /v2/sales-orders/{uuid}/sales-order-items` resolves **`SalesOrders` as the first route pair**
|
|
147
|
+
(`api2/Component/Api/V2/V2.php:3185-3214`), so a `(recordId 14, PRE, POST)` row **does** fire — and
|
|
148
|
+
the payload handed to it is the **raw decoded request body**, which on that route is a **single
|
|
149
|
+
line-item object with no `salesOrderItems` wrapper**.
|
|
150
|
+
|
|
151
|
+
**Consequence: a collector written as `foreach ($payload->salesOrderItems as …)` silently no-ops on
|
|
152
|
+
exactly the requests you most wanted to guard.** A parent-record PRE hook must handle both shapes:
|
|
153
|
+
|
|
154
|
+
```php
|
|
155
|
+
$hasSalesOrderItems = (is_object($node) && isset($node->salesOrderItems) && is_array($node->salesOrderItems));
|
|
156
|
+
$lines = $hasSalesOrderItems ? $node->salesOrderItems : [$node];
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
…and, since a payload may arrive as one node or a list, wrap that in
|
|
160
|
+
`is_array($payload) ? $payload : [$payload]` first.
|
|
161
|
+
|
|
162
|
+
**The flat sibling route is a DIFFERENT record and is not covered.**
|
|
163
|
+
`PUT /v2/sales-order-items/{uuid}` is **records id 15**, so a record-14 interceptor never sees it.
|
|
164
|
+
Whether that gap matters is a design decision, not an oversight to close reflexively — see
|
|
165
|
+
[inactive-item purchase gating](../../toga2-commerce/features/inactive-item-purchase-gating.md),
|
|
166
|
+
where a record-15 `prePut` was **deliberately not** registered because it would have blocked
|
|
167
|
+
quantity edits on pre-existing lines.
|
|
168
|
+
|
|
169
|
+
**Do not assume the client sends nested children.** TOGa Commerce never does: its header sync
|
|
170
|
+
`delete`s `salesOrderItems` from the edit `PUT` payload and writes lines **one at a time** — new
|
|
171
|
+
lines through the nested `POST` above, existing lines through the flat record-15 `PUT`. So the
|
|
172
|
+
route pair that actually carries your data may not be the one the API docs suggest. See
|
|
173
|
+
[order-submit sync sequencing](../../toga2-commerce/features/order-submit-sync-sequencing.md).
|
|
174
|
+
|
|
140
175
|
## The dispatch is UNGUARDED — a row naming a method that does not exist hard-fatals the endpoint
|
|
141
176
|
|
|
142
177
|
The derived name (step 2 above) is **pure convention with nothing validating it**, and there is
|
|
@@ -354,6 +389,22 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
354
389
|
change that switches on a *code* path; if the PHP defining the method is not confirmed deployed to
|
|
355
390
|
that environment, the row takes the endpoint down. Insert inactive, verify the deploy, then flip
|
|
356
391
|
`isActive = 1` — and remember every environment activates independently.
|
|
392
|
+
- **⚠ Guard the INSERT with `NOT EXISTS` on `(apiId IS NULL, recordId, prePostProcessing,
|
|
393
|
+
httpMethod)` — and know what that costs you.** The real key is **four** columns and api-scoped
|
|
394
|
+
rows legitimately coexist with an `apiId NULL` row, so a two- or three-column guard would suppress
|
|
395
|
+
a row it should have inserted. The trade-off: **the guard also makes a later `isActive` flip in
|
|
396
|
+
the same file a no-op**. An environment that already ran the migration at `isActive = 0` is *not*
|
|
397
|
+
activated by re-running the edited file at `isActive = 1` — that environment needs a manual
|
|
398
|
+
`UPDATE`. Worked example (both Compass tenants, 2026-08-14):
|
|
399
|
+
`dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql`.
|
|
400
|
+
- **⚠ Drift also runs the other way: a row that PRODUCTION already removed can still be live in
|
|
401
|
+
non-prod.** The `_Model_Compass_Usa_SalesOrderItem::postPost()` outage below was fixed in
|
|
402
|
+
production by `dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql`,
|
|
403
|
+
but that migration had **never been run on dev-sandbox/beta**, so the same row was still there on
|
|
404
|
+
2026-08-14 and every Compass order placed on beta returned `500 EO-1`
|
|
405
|
+
(`Logs.Issue` id 11: *"Call to undefined method `_Model_Compass_Usa_SalesOrderItem::postPost()`"*).
|
|
406
|
+
Deleting the row fixed it. **When a write endpoint 500s only in non-prod, look for an EXTRA row a
|
|
407
|
+
cleanup migration removed upstream — not only for a missing one.**
|
|
357
408
|
- **⚠ A post interceptor's `$payload` is the record, not a route-keyed envelope** — and it is
|
|
358
409
|
by-reference, so pull the record into a local instead of reassigning `$payload`.
|
|
359
410
|
- **⚠ Validate that a payload ARRAY's elements are objects before touching them — `property_exists()`
|
|
@@ -397,6 +448,23 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
397
448
|
|
|
398
449
|
## Change history
|
|
399
450
|
|
|
451
|
+
- 2026-08-14 — TRUE-80489, from building the Compass inactive-standalone-item order guard.
|
|
452
|
+
(1) **A PRE interceptor on a PARENT record fires on the nested child POST, with the CHILD as the
|
|
453
|
+
payload.** `POST /sales-orders/{uuid}/sales-order-items` resolves `SalesOrders` as the
|
|
454
|
+
first route pair (`V2.php:3185-3214`) and hands the hook the **raw decoded body** — a single
|
|
455
|
+
line-item object with **no `salesOrderItems` wrapper** — so a collector that only reads
|
|
456
|
+
`$payload->salesOrderItems` silently no-ops on exactly those requests; handle both shapes. The
|
|
457
|
+
flat sibling `PUT /sales-order-items/{uuid}` is **records id 15** and is not covered by a
|
|
458
|
+
record-14 row at all. Also recorded that **TOGa Commerce never sends nested lines**: it `delete`s
|
|
459
|
+
`salesOrderItems` from the header `PUT` and writes each line individually. (2) Added the
|
|
460
|
+
**`NOT EXISTS` idempotency guard** keyed on the real four-column key
|
|
461
|
+
(`apiId IS NULL + recordId + prePostProcessing + httpMethod`, because api-scoped rows can
|
|
462
|
+
legitimately coexist) — and the catch that the same guard makes a later `isActive 0 → 1` edit of
|
|
463
|
+
that file a **no-op** in any environment that already ran it. (3) Recorded the **inverse drift
|
|
464
|
+
case**: the broken `_Model_Compass_Usa_SalesOrderItem::postPost()` row that production removed via
|
|
465
|
+
the 2026-08-06 cleanup migration was **still live on dev-sandbox/beta**, 500-ing every Compass
|
|
466
|
+
order on beta (`Logs.Issue` id 11) until it was deleted — so a non-prod-only write failure can be
|
|
467
|
+
an **extra** row, not a missing one. (bala)
|
|
400
468
|
- 2026-08-13 — TRUE-80412, from building the Compass zero-line-item PO guard. (1) Recorded the
|
|
401
469
|
lookup predicate — **`apiId IS NULL OR apiId = <caller's api>`** — and the verified row set for
|
|
402
470
|
`recordId 17` (`purchase-orders`): **no `Core` row at all**, and a `PRE` + `POST` pair in **each**
|
|
@@ -89,6 +89,30 @@ response that walks that collection can 500. Two defences, both cheap:
|
|
|
89
89
|
The fragility itself is **unfixed** — any depth ≥ 2 response is exposed. Lowering depth removes
|
|
90
90
|
*your* exposure, not the defect.
|
|
91
91
|
|
|
92
|
+
### ⚠ A `where` clause on a single-uuid READ is silently IGNORED
|
|
93
|
+
|
|
94
|
+
**`GET /v2/items/{uuid}` does not filter.** The by-uuid READ path loads the record through
|
|
95
|
+
`locateRecord()`, which resolves by uuid and **never reads `httpOptions['where']`**. The parameter
|
|
96
|
+
is accepted, produces no error, and has no effect — so a caller that adds
|
|
97
|
+
`where=(Items.isActive:eq:1)` to a single-uuid GET gets the record back **regardless of the
|
|
98
|
+
filter** and believes it has been gated.
|
|
99
|
+
|
|
100
|
+
**Use the LIST route instead**, and normalize the result:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
GET /v2/items?where=(Items.uuid:eq:<uuid>,AND,Items.isActive:eq:1)&recordsPerPage=1
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The list route returns `data.items` as an **array**; the single-uuid route returns a single
|
|
107
|
+
**object**. Callers that switch routes must normalize `data.items[0] ?? null` back to the shape the
|
|
108
|
+
rest of the code expects — a `null` there is then a truthful *"missing or filtered out."* Worked
|
|
109
|
+
example: [TOGa Commerce inactive-item purchase
|
|
110
|
+
gating](../../toga2-commerce/features/inactive-item-purchase-gating.md).
|
|
111
|
+
|
|
112
|
+
This is the mirror image of the LIST → READ flip below: there, an unrecognized param moves you onto
|
|
113
|
+
the READ path and you get a loud 404; here, a **recognized** param is simply inert on that path and
|
|
114
|
+
you get a quiet wrong answer.
|
|
115
|
+
|
|
92
116
|
### CRITICAL — an unrecognized param silently flips LIST → READ
|
|
93
117
|
|
|
94
118
|
Any query param the engine does not recognize switches the request onto a **different code
|
|
@@ -161,6 +185,10 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
|
|
|
161
185
|
you never requested is expected, not a red herring; (b) **every** route whose traversal reaches that
|
|
162
186
|
record fails identically, so narrowing `fields=` cannot work around it. The fix is schema-side —
|
|
163
187
|
see [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md).
|
|
188
|
+
- **⚠ `where` does nothing on `/{route}/{uuid}`.** The by-uuid READ loads via `locateRecord()` and
|
|
189
|
+
never reads `httpOptions['where']`. There is no error — you get the record back unfiltered. If a
|
|
190
|
+
filter must apply, use the LIST route with `Items.uuid:eq:<uuid>` in the `where` and normalize
|
|
191
|
+
`data.items[0]`.
|
|
164
192
|
- **A 404 on a route you know exists ⇒ suspect an unrecognized query param** (LIST → READ flip)
|
|
165
193
|
before suspecting the route.
|
|
166
194
|
- **A 500 (`EO-1`) on a filter ⇒ suspect a `where` reference to an unjoined table.**
|
|
@@ -169,6 +197,13 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
|
|
|
169
197
|
invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
|
|
170
198
|
|
|
171
199
|
## Change history
|
|
200
|
+
- 2026-08-14 — Recorded that **a `where` clause on a single-uuid READ is silently ignored**:
|
|
201
|
+
`GET /v2/items/{uuid}` resolves through `locateRecord()`, which never reads
|
|
202
|
+
`httpOptions['where']`, so the filter is accepted, errors nothing, and returns the record
|
|
203
|
+
regardless. The LIST form (`GET /v2/items?where=(Items.uuid:eq:…,AND,Items.isActive:eq:1)`) is the
|
|
204
|
+
only shape where the filter applies, and switching routes changes the payload from a single object
|
|
205
|
+
to an array that the caller must normalize (`data.items[0] ?? null`). Found while gating inactive
|
|
206
|
+
items out of the TOGa Commerce item page. (bala)
|
|
172
207
|
- 2026-08-11 — **Corrected the sort contract and documented the `DISTINCT` LIST select.** The old
|
|
173
208
|
"do not send `+`" was wrong as an absolute: `V2.php` ~L3673–3682 explicitly handles `+` as ASC.
|
|
174
209
|
The real caveat is encoding — the query string is parsed without urldecoding, so a
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Database Changes (dbchanges2) Repository Architecture](architecture.md) | `dbchanges2` is the **schema-migration / SQL change-set repository** for the entire 2.0 platform. | Core/, Client/, Client_<Tenant>/, Logs/, Logs_Client/, _modules/ |
|
|
6
|
-
| [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql |
|
|
6
|
+
| [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql, dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql, dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql |
|
|
7
7
|
| [2.0 New-Client Onboarding (manual process)](workflows/client-onboarding.md) | > **A local browser wizard now automates this.** Steps 2–9 below (create DBs, generate Core/API > inserts, append to `Clients_Db.txt`) — plus the dbchanges2 bla | Client/, Client_<Tenant>/, Core/, Logs_Client/ |
|
|
8
8
|
| [Auditing a client DB that drifted from its models (partially applied module migration)](workflows/client-schema-drift-audit.md) | A recurring 2.0 failure mode: **one client's database drifts from what the PHP models declare**, usually because a `_modules/<module>/` migration was applied to | dbchanges2/Client_Growrk/2026-05-28.sql, dbchanges2/Client_Growrk/2026-08-10c - GrowrkServiceRequestCustomFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10d - GrowrkServiceRequestTypeAndDispositionSeeds.sql, dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql, dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql, dbchanges2/Client_Growrk/_modules.txt |
|
|
9
9
|
| [Local vs prod MySQL config parity — why “it passed locally” is not evidence](workflows/local-vs-prod-mysql-config-parity.md) | Several migration failures that look like "prod-only bugs" are actually **per-machine MySQL server-configuration differences**. | |
|
|
10
|
-
| [Repairing non-prod metadata drift (works in prod, broken in beta/dev-sandbox)](workflows/nonprod-metadata-drift-repair.md) | Almost all 2.0 platform behavior is **metadata** — `Core.Records`/`RecordFields`, `Core.RecordScripts`, `Core.ApiPayloadInterceptors`, and per-client `Acl*` row | dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, api2/Config/beta.ini |
|
|
10
|
+
| [Repairing non-prod metadata drift (works in prod, broken in beta/dev-sandbox)](workflows/nonprod-metadata-drift-repair.md) | Almost all 2.0 platform behavior is **metadata** — `Core.Records`/`RecordFields`, `Core.RecordScripts`, `Core.ApiPayloadInterceptors`, and per-client `Acl*` row | dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, api2/Config/beta.ini |
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-08-14
|
|
10
10
|
owners: [jcardinal, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql
|
|
@@ -66,6 +66,8 @@ files:
|
|
|
66
66
|
- dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql
|
|
67
67
|
- dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql
|
|
68
68
|
- dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql
|
|
69
|
+
- dbchanges2/Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql
|
|
70
|
+
- dbchanges2/Core/2026-08-05b - ApprovalDetailsShippingMethodConcatCharge.sql
|
|
69
71
|
related:
|
|
70
72
|
- ../../_underscore/features/surface-resolver.md
|
|
71
73
|
---
|
|
@@ -512,6 +514,19 @@ rule resumes.
|
|
|
512
514
|
widening for the rule attributes); and for the `/surfaces/meta` endpoint to resolve at all, the ACL
|
|
513
515
|
dispatch grants (`Client/2026-06-25c`, `2026-06-29c`, `2026-06-30a`). Skipping the table/ENUM
|
|
514
516
|
bring-up makes the `2026-07-17a/b` override inserts fail or silently coerce.
|
|
517
|
+
- **A `SurfaceElements` seed guard keyed on `config.valueKey` is NOT durably idempotent.** If a
|
|
518
|
+
later migration mutates that same valueKey, the guard goes blind and a re-run inserts a
|
|
519
|
+
**duplicate** element (same sortOrder/label/valueKey, different uuid/id) — the field then renders
|
|
520
|
+
twice. Proven case: `Core/2026-07-29a - SalesOrderApprovalDetailsSurfaceSeed.sql` inserts the
|
|
521
|
+
sales-order-approval-details "Shipping Method" element guarded by
|
|
522
|
+
`NOT EXISTS (... config.valueKey = 'shippingMethod.display')`; `Core/2026-08-05b -
|
|
523
|
+
ApprovalDetailsShippingMethodConcatCharge.sql` later UPDATEs that valueKey to `shippingMethod.name`
|
|
524
|
+
(+ `isConcatenated` charge). After that mutation, re-running `07-29a` sees no `.display` element and
|
|
525
|
+
inserts a **second** shipping-method element; re-running `08-05b` then converts the dup to `.name`
|
|
526
|
+
too. **Rule:** key idempotency guards on a **stable** identifier (`labelMessageId` / the message
|
|
527
|
+
key) that display-tweak migrations don't touch — never on `config.valueKey`. (Cleanup here was a
|
|
528
|
+
one-off manual DELETE of the higher-id dup on the developer's local DB — local operator error from
|
|
529
|
+
running seeds twice, deliberately not committed to dbchanges2.)
|
|
515
530
|
- **"Enter PO Details" (element 32) has a DUAL visibility gate.** Its rendering is gated by BOTH the
|
|
516
531
|
element-level `isVisible` flag AND a `config`-embedded `"isVisible":false` (migrated from Quad's
|
|
517
532
|
legacy field JSON, alongside `isEnabled`/`isComplete`). Flipping the element-level `IS_VISIBLE=1`
|
|
@@ -521,6 +536,15 @@ rule resumes.
|
|
|
521
536
|
override is added). **Open follow-up.**
|
|
522
537
|
|
|
523
538
|
## Change history
|
|
539
|
+
- 2026-08-14 — Recorded a durable idempotency-guard gotcha (below): a `SurfaceElements` seed
|
|
540
|
+
guard keyed on `config.valueKey` is defeated by any later migration that mutates that same
|
|
541
|
+
valueKey, causing duplicate element inserts on re-run. Concrete case: `Core/2026-07-29a`
|
|
542
|
+
guards the approval-details "Shipping Method" element on `config.valueKey='shippingMethod.display'`,
|
|
543
|
+
and `Core/2026-08-05b` later UPDATEs it to `shippingMethod.name`; re-running `07-29a` then no
|
|
544
|
+
longer sees a `.display` element and inserts a second one. Cleanup was a one-off manual DELETE on
|
|
545
|
+
the developer's local DB (local operator error from re-running seeds), deliberately **not** added
|
|
546
|
+
to dbchanges2. Rule: guard seed elements on a **stable** identifier (labelMessageId / message key),
|
|
547
|
+
not on `config.valueKey` which display-tweak migrations commonly change. (apeterson)
|
|
524
548
|
- 2026-07-31 — **Closed the "audit the cross-DB surface seeds" open item** (TRUE-80487): swept all
|
|
525
549
|
505 non-`HISTORIC` `.sql` files and found **24** cross-tenant references (13 in `Client/`,
|
|
526
550
|
including the `BLANK_CLIENT_DATABASE` new-client template; 3 in `Client_Rate/`; 2 in `Core/`
|
|
@@ -6,10 +6,11 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-14
|
|
10
10
|
owners: ["mhammontree", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
|
|
13
|
+
- dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
|
|
13
14
|
- dbchanges2/Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql
|
|
14
15
|
- dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql
|
|
15
16
|
- api2/Config/beta.ini
|
|
@@ -47,6 +48,16 @@ exercise TOGa Supply Fulfill & Ship (TRUE-80494).
|
|
|
47
48
|
(dev-sandbox was missing `(recordId 28, PRE, POST)`, so `prePost` never defaulted
|
|
48
49
|
`itemFulfillmentStageId` → `EV-10 Column 'itemFulfillmentStageId' cannot be null`). Apply the
|
|
49
50
|
existing migration rather than writing a new one.
|
|
51
|
+
**⚠ Diff in BOTH directions — the drift is just as often an EXTRA row.** A *cleanup* migration
|
|
52
|
+
that removed a bad row in production is exactly as easy to forget in non-prod as an additive one.
|
|
53
|
+
Worked example (2026-08-14): `Client_Compass/2026-08-06 -
|
|
54
|
+
RemoveBrokenSalesOrderItemPostPostInterceptor.sql` had never been run on dev-sandbox, so the row
|
|
55
|
+
deriving `postPost` on `_Model_Compass_Usa_SalesOrderItem` (which does not define it) was still
|
|
56
|
+
active there and **every Compass order placed on beta returned `500 EO-1`** — *"Call to undefined
|
|
57
|
+
method"*, `Logs.Issue` id 11 — while production was fine. Symptom-wise this is indistinguishable
|
|
58
|
+
from a code regression on the branch under test, which is exactly why it burns time: the branch
|
|
59
|
+
gets blamed. Deleting the row fixed it. **A "works in prod, broken in beta" report should trigger
|
|
60
|
+
a full-table diff, not a re-read of the diff you just wrote.**
|
|
50
61
|
3. **For a field that reads back empty/blank, check `AclFieldPermissions` before the selector.** An
|
|
51
62
|
ungranted field is **silently omitted** from the GET body (HTTP 200, no message). `Measures.measureType`
|
|
52
63
|
had zero grant rows in dev-sandbox `Client_Growrk` → omitted from `GET /v2/measures` → the frontend's
|
|
@@ -145,6 +156,13 @@ blocked by the id-divergence hazard in item 2 above, which is why both go to jca
|
|
|
145
156
|
|
|
146
157
|
## Change history
|
|
147
158
|
|
|
159
|
+
- 2026-08-14 — Recorded that metadata drift runs in **both** directions: a cleanup migration that
|
|
160
|
+
*removed* a row in production is as easily skipped in non-prod as an additive one. Evidence —
|
|
161
|
+
`Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql` had never run on
|
|
162
|
+
dev-sandbox, so the broken `postPost` interceptor row was still active there and **every Compass
|
|
163
|
+
order placed on beta 500'd (`EO-1`, `Logs.Issue` id 11)** while production was healthy; the
|
|
164
|
+
failure looked exactly like a regression in the feature branch being tested. Step 2 now says to
|
|
165
|
+
diff the table wholesale in both directions. (bala)
|
|
148
166
|
- 2026-08-10 — Added a disambiguation pointer to the new
|
|
149
167
|
[client schema-drift audit](./client-schema-drift-audit.md) for the **inverse** case (production or
|
|
150
168
|
a single client's DB is the outlier, e.g. a partially applied `_modules/` migration). No change to
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
| [Cart Bundle Submission & the bundleUuid Identity Contract](features/cart-bundle-submission-and-identity.md) | How cart **bundles** (kits) are turned into `SalesOrderItems` when a cart is submitted or an existing order is edited, and the **identity-field contract** every | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts, src/utils/formatSalesOrderBundlesFromApi.ts, src/stores/useCartStoreZu.ts, src/pages/OrderDetails/helpers/formatSalesOrderDataFromLocalStorage.ts, src/pages/OrderDetails/view/components/OrderItems.tsx |
|
|
7
7
|
| [Cart Notification Emails — duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/stores/useEmailOptionsStore.ts, src/stores/useCartSalesQuoteZu.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts |
|
|
8
8
|
| [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | The Cart page (`src/pages/Cart/`) is the most config-heavy page in `toga2-commerce`. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/pages/Cart/view/cartForm/CartFormSection.tsx, src/pages/Cart/view/cartForm/CartFormRenderer.tsx, src/pages/Cart/view/EditCart.tsx, src/pages/Cart/view/EditOrder.tsx, src/pages/Cart/viewModel/useEditOrderOrEditCartViewModel.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts, src/hooks/useAssignClientFields.ts |
|
|
9
|
+
| [Catalog cache freshness — the 24h persisted query cache, and how to opt a query out of it](features/catalog-cache-freshness.md) | TOGa Commerce runs a **single `QueryClient` with a 24-hour default `staleTime`**, and persists it to **`localStorage["commerce"]`** through `PersistQueryClientP | toga2-commerce/src/App.tsx, toga2-commerce/src/contexts/AuthContext.tsx, toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts |
|
|
9
10
|
| [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | **"Move item X to the front of category Y" is a DATA change, not a code change.** The order of item tiles on a storefront category page is driven by exactly one | src/pages/Filter/api/FilterApi.ts, src/pages/Filter/viewModel/useFilterViewModel.ts, api2/Component/Api/V2/V2.php, toga2-supply/src/pages/Items/api/itemsApi.ts |
|
|
10
11
|
| [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. | src/pages/Account/view/MySettingsView.tsx, src/contexts/helpers/getLoginSettings.ts, src/pages/Account/viewModel/useAccountViewModel.ts, src/fieldsConfig/index.ts, src/fieldsConfig/getClientLoginFields.ts, src/fieldsConfig/clientFields/COMPASS.json, src/fieldsConfig/clientFields/COMPASSCANADA.json, src/fieldsConfig/clientFields/QUAD.json, src/pages/Cart/api/CartApi.ts, src/hooks/useAuthenticationFlow.ts, src/contexts/AuthContext.tsx, src/pages/Login/viewModel/useLoginPageViewModel.ts, src/hooks/useAssignClientFields.ts, src/hooks/useDynamicConditionalFieldOptions.ts, src/stores/useFieldsStore.ts, src/components/BaseDetailField/BaseDetailField.tsx, src/components/NavIcons/NavIconItem.tsx, src/components/Submenus/AlertSubmenu.tsx, src/components/Submenus/types.ts, src/pages/Account/AccountPage.tsx, src/pages/Account/view/MyOrdersView.tsx, src/pages/GetSupport/GetSupportPage.tsx, src/pages/GetSupport/viewModel/useGetSupportViewModel.ts, src/queries/queries.ts, src/App.tsx, src/pages/Filter/FilterPage.tsx, src/pages/Filter/viewModel/FIELDS/COMPASS/ENGLISH/USER/FILTERPAGEFIELDS.json |
|
|
11
12
|
| [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | On the toga2-commerce **Cart** page, expedited shipping options (**"2nd Day EOB"** and **"Next Day Air"**) are only offered in the *Shipping Method* dropdown wh | toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/CartPage.tsx |
|
|
12
13
|
| [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | The storefront has **two distinct search entry points that render the same card component through completely different code paths and different FIELDS files**. | src/pages/Filter/FilterPage.tsx, src/pages/Filter/viewModel/useFilterViewModel.ts, src/pages/Filter/viewModel/FIELDS/COMPASS/ENGLISH/USER/FILTERPAGEFIELDS.json, src/pages/Filter/viewModel/FIELDS/COMPASSCANADA/FRENCH/USER/FILTERPAGEFIELDS.json, src/pages/Filter/viewModel/FIELDS/QUAD/ENGLISH/BUYER/FILTERPAGEFIELDS.json, src/components/Header/Header.tsx, src/pages/Home/view/components/BundlesSection.tsx, src/pages/Home/viewModel/FIELDS/COMPASS/ENGLISH/USER/HOMEPAGEFIELDS.json, src/components/Cards/BundleViewCard.tsx, src/utils/renderBadge.tsx, src/hooks/useAssignClientFields.ts |
|
|
14
|
+
| [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | An item with **`Items.isActive = 0`** must not be viewable, addable to a cart, or orderable **as a standalone line** on the storefront — but the **same flag is | toga2-commerce/src/utils/checkIsItemPurchasable.ts, toga2-commerce/src/api/fetchItemsActiveStatus.ts, toga2-commerce/src/hooks/useCartReconciliation.ts, toga2-commerce/src/stores/useCartStoreZu.ts, toga2-commerce/src/pages/ItemsView/api/ItemsApi.ts, toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts, toga2-commerce/src/pages/ItemsView/ItemViewPage.tsx, toga2-commerce/src/components/AuthLayout/AuthLayout.tsx, toga2-commerce/src/pages/Cart/CartPage.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartTableBundleItem.tsx, toga2-commerce/src/api/syncSalesOrderFromApiToLocalStorage.ts, _underscore/Model/Compass/SalesOrder.php, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
|
|
13
15
|
| [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. | src/themeConfig/themes.json, src/themeConfig/ThemeContext.tsx, src/themeConfig/types.ts, src/components/ThemeSwitcher/ThemeSwitcher.tsx, src/components/AuthLayout/AuthLayout.tsx, src/api/axiosInstance.ts, src/contexts/AuthContext.tsx, tailwind.config.js |
|
|
14
16
|
| [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | Submitting an order from the cart fires **two independent sync routines** — one for the sales-order header (`syncSalesOrderData`) and one for the line items (`s | toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts, toga2-commerce/src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts, toga2-commerce/src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
|
|
15
17
|
| [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. | toga2-commerce/amplify.yml, toga2-commerce/.gitattributes, toga2-commerce/package.json, toga2-commerce/.github/workflows/sync-stage-environments.yml |
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Catalog cache freshness — the 24h persisted query cache, and how to opt a query out of it
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-14
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- toga2-commerce/src/App.tsx
|
|
13
|
+
- toga2-commerce/src/contexts/AuthContext.tsx
|
|
14
|
+
- toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts
|
|
15
|
+
related:
|
|
16
|
+
- ./inactive-item-purchase-gating.md
|
|
17
|
+
- ../architecture.md
|
|
18
|
+
- ../../toga25-supply/features/force-logout-on-deployment.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
|
|
23
|
+
TOGa Commerce runs a **single `QueryClient` with a 24-hour default `staleTime`**, and persists it
|
|
24
|
+
to **`localStorage["commerce"]`** through `PersistQueryClientProvider`. That combination means the
|
|
25
|
+
storefront can serve **catalogue state that is up to 24 hours out of date, across full page
|
|
26
|
+
reloads** — and it does so **in both directions**: after an item is switched off, a user who had
|
|
27
|
+
already viewed it keeps seeing the live product page with a working Add to Cart; after it is
|
|
28
|
+
switched back on, it stays hidden.
|
|
29
|
+
|
|
30
|
+
Any query whose answer **gates an action** (can this be bought? is this still available?) must be
|
|
31
|
+
opted out of *both* mechanisms. Opting out of one is not enough.
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
### The default: cached and persisted
|
|
36
|
+
|
|
37
|
+
`src/App.tsx` creates the client with `staleTime: 1000 * 60 * 60 * 24` and a
|
|
38
|
+
`createSyncStoragePersister` on `localStorage` key `"commerce"`. Everything inherits that unless it
|
|
39
|
+
overrides it — including the `["clientFields", …]` query that carries the per-tenant `FIELDS`
|
|
40
|
+
content, which is why copy/config deploys also look stale for returning users (see the
|
|
41
|
+
[architecture gotchas](../architecture.md#gotchas)).
|
|
42
|
+
|
|
43
|
+
### The platform's cache-bust: `META_LAST_REFRESH_DATETIME`
|
|
44
|
+
|
|
45
|
+
The **only** mechanism that clears the persisted cache for everyone is the Core parameter
|
|
46
|
+
**`META_LAST_REFRESH_DATETIME`** (`Core.Parameters` id 32). `src/contexts/AuthContext.tsx` polls it
|
|
47
|
+
with `staleTime: 0` **on every route change and on window focus**; when the value differs from the
|
|
48
|
+
one captured at login it calls `logout()` and forces a full reload, which clears the saved cache.
|
|
49
|
+
|
|
50
|
+
> **⚠ Bumping that parameter logs out EVERY user on that environment.** It is a deployment-grade
|
|
51
|
+
> lever, not a way to refresh one catalogue change. The sibling implementation in TOGa 2.5 Supply
|
|
52
|
+
> is documented in
|
|
53
|
+
> [force logout on deployment](../../toga25-supply/features/force-logout-on-deployment.md).
|
|
54
|
+
|
|
55
|
+
### Opting a single query out — `staleTime: 0` + `gcTime: 0` is NOT sufficient on its own
|
|
56
|
+
|
|
57
|
+
The item-detail queries (`fetchItemDetails`, `fetchItemDetailsViaBundle`) are set to
|
|
58
|
+
`staleTime: 0` **and** `gcTime: 0`, **and** excluded from persistence in `App.tsx`:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const NON_PERSISTED_QUERY_KEYS: string[] = ["fetchItemDetails", "fetchItemDetailsViaBundle"];
|
|
62
|
+
|
|
63
|
+
const shouldDehydrateQuery = (query) => {
|
|
64
|
+
const queryName = query.queryKey?.[0];
|
|
65
|
+
if (typeof queryName === "string" && NON_PERSISTED_QUERY_KEYS.includes(queryName)) return false;
|
|
66
|
+
return query.state.status === "success";
|
|
67
|
+
};
|
|
68
|
+
// … persistOptions: { dehydrateOptions: { shouldDehydrateQuery } }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Why the exclusion is required:** `gcTime: 0` only evicts a query once it becomes **inactive**. An
|
|
72
|
+
**active** query — exactly the state the item page is in while the user is looking at it — is still
|
|
73
|
+
dehydrated and written to `localStorage`, and is rehydrated on the next load. So `gcTime: 0` alone
|
|
74
|
+
leaves the stale answer on disk.
|
|
75
|
+
|
|
76
|
+
The default branch (`query.state.status === "success"`) preserves TanStack's normal behaviour for
|
|
77
|
+
every other query, so this is an allowlist-shaped opt-out with no app-wide side effects.
|
|
78
|
+
|
|
79
|
+
**Listings were deliberately left on the 24h cache.** Only the item-detail path gates a purchase
|
|
80
|
+
decision; making every listing uncached would trade a real performance characteristic for no
|
|
81
|
+
correctness gain.
|
|
82
|
+
|
|
83
|
+
## Gotchas / known issues
|
|
84
|
+
|
|
85
|
+
- **⚠ Never verify a catalogue or FIELDS change on a warm browser.** Clear
|
|
86
|
+
`localStorage.removeItem('commerce')` or log out first. A "it didn't deploy" report is usually a
|
|
87
|
+
rehydrated cache.
|
|
88
|
+
- **⚠ `gcTime: 0` does not stop persistence.** Active queries are still dehydrated. Exclude the key
|
|
89
|
+
via `dehydrateOptions.shouldDehydrateQuery` as well.
|
|
90
|
+
- **⚠ `META_LAST_REFRESH_DATETIME` is not a targeted invalidation** — it force-logs-out every user
|
|
91
|
+
on the environment. Use per-query opt-out or `queryClient.invalidateQueries` for anything
|
|
92
|
+
narrower.
|
|
93
|
+
- **A `persistOptions.buster` keyed on the build version** would fix stale FIELDS at deploy time but
|
|
94
|
+
invalidates every persisted query app-wide — still an **open team decision, not implemented**
|
|
95
|
+
(carried over from the architecture doc).
|
|
96
|
+
- **`AuthLayout` never remounts**, so `refetchOnMount` fires once per session for queries it owns —
|
|
97
|
+
a route-change refresh has to be wired explicitly. That interacts with everything above: a query
|
|
98
|
+
can be both stale *and* never refetched during a session.
|
|
99
|
+
|
|
100
|
+
## Change history
|
|
101
|
+
|
|
102
|
+
- 2026-08-14 — Created while building the
|
|
103
|
+
[inactive-item purchase gate](./inactive-item-purchase-gating.md). Recorded that the 24h
|
|
104
|
+
persisted cache masks catalogue state **in both directions** (a switched-off item kept rendering
|
|
105
|
+
a working Add to Cart for anyone who had already viewed it; a switched-back-on item stayed
|
|
106
|
+
hidden), that the platform's only cache-bust is the Core parameter `META_LAST_REFRESH_DATETIME`
|
|
107
|
+
(`Core.Parameters` id 32, polled in `AuthContext` at `staleTime: 0` on route change and window
|
|
108
|
+
focus, and which **logs out every user on the environment**), and — the non-obvious part — that
|
|
109
|
+
`staleTime: 0` + `gcTime: 0` is **not** sufficient because `gcTime` only evicts *inactive*
|
|
110
|
+
queries while an *active* query is still dehydrated and persisted, so the item-detail keys also
|
|
111
|
+
had to be excluded via `dehydrateOptions.shouldDehydrateQuery` in `App.tsx`. Listings
|
|
112
|
+
deliberately left on the default cache. (bala)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Inactive-item purchase gating (standalone lines only — kits are exempt by design)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-14
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- toga2-commerce/src/utils/checkIsItemPurchasable.ts
|
|
13
|
+
- toga2-commerce/src/api/fetchItemsActiveStatus.ts
|
|
14
|
+
- toga2-commerce/src/hooks/useCartReconciliation.ts
|
|
15
|
+
- toga2-commerce/src/stores/useCartStoreZu.ts
|
|
16
|
+
- toga2-commerce/src/pages/ItemsView/api/ItemsApi.ts
|
|
17
|
+
- toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts
|
|
18
|
+
- toga2-commerce/src/pages/ItemsView/ItemViewPage.tsx
|
|
19
|
+
- toga2-commerce/src/components/AuthLayout/AuthLayout.tsx
|
|
20
|
+
- toga2-commerce/src/pages/Cart/CartPage.tsx
|
|
21
|
+
- toga2-commerce/src/pages/Cart/view/cartTable/CartTableBundleItem.tsx
|
|
22
|
+
- toga2-commerce/src/api/syncSalesOrderFromApiToLocalStorage.ts
|
|
23
|
+
- _underscore/Model/Compass/SalesOrder.php
|
|
24
|
+
- dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
|
|
25
|
+
- dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
|
|
26
|
+
related:
|
|
27
|
+
- ./catalog-cache-freshness.md
|
|
28
|
+
- ./order-submit-sync-sequencing.md
|
|
29
|
+
- ./cart-bundle-submission-and-identity.md
|
|
30
|
+
- ../architecture.md
|
|
31
|
+
- ../../api2/features/api-payload-interceptors.md
|
|
32
|
+
- ../../api2/features/v2-rest-query-contract.md
|
|
33
|
+
- ../../../clients/compass-usa/profile.md
|
|
34
|
+
- ../../../clients/compass-canada/profile.md
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Summary
|
|
38
|
+
|
|
39
|
+
An item with **`Items.isActive = 0`** must not be viewable, addable to a cart, or orderable **as a
|
|
40
|
+
standalone line** on the storefront — but the **same flag is used for a completely different
|
|
41
|
+
purpose on kit components**, and nothing in the schema separates the two meanings.
|
|
42
|
+
|
|
43
|
+
**A Compass kit hero is switched off ON PURPOSE so it cannot be bought separately.** Measured on
|
|
44
|
+
dev-sandbox `Client_Compass`, across bundles that are themselves active:
|
|
45
|
+
|
|
46
|
+
| Component tier | inactive | active |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| PRIMARY | 87 | 4 |
|
|
49
|
+
| secondary | 134 | 216 |
|
|
50
|
+
| tertiary | 86 | 520 |
|
|
51
|
+
|
|
52
|
+
**84 of 85 active bundles contain at least one inactive component.** Canada is the same shape
|
|
53
|
+
(32 inactive vs 3 active primaries). So a gate on the flag alone would break almost every kit on
|
|
54
|
+
the storefront.
|
|
55
|
+
|
|
56
|
+
**Therefore the gate is on the purchase CONTEXT, never on the flag alone.** The discriminator is
|
|
57
|
+
whether the line belongs to a kit: `isset($payload->bundleItem)` server-side, the bundle wrapper
|
|
58
|
+
client-side. Kit lines are never evaluated.
|
|
59
|
+
|
|
60
|
+
## How it works
|
|
61
|
+
|
|
62
|
+
Five layers, front to back. Each one is independently sufficient for its own entry point; together
|
|
63
|
+
they close the paths a single check would leave open.
|
|
64
|
+
|
|
65
|
+
| # | Layer | Where |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| 1 | Item page renders an "unavailable" panel instead of Add to Cart | `ItemViewPage.tsx`, `useItemDetailsViewModel.ts` |
|
|
68
|
+
| 2 | One shared purchasability helper, **fails closed** | `src/utils/checkIsItemPurchasable.ts` |
|
|
69
|
+
| 3 | Guard inside the cart store's `addItem` | `src/stores/useCartStoreZu.ts` |
|
|
70
|
+
| 4 | Cart reconciliation against the live catalog | `useCartReconciliation.ts`, `CartPage.tsx` |
|
|
71
|
+
| 5 | Server-side PRE interceptor on the order write | `_Model_Compass_SalesOrder::prePost` / `::prePut` |
|
|
72
|
+
|
|
73
|
+
### 1–2. The helper fails CLOSED, and that is the whole point
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const ACTIVE_VALUES: unknown[] = [true, 1, '1'];
|
|
77
|
+
// returns false for a missing item, a non-object, and an absent isActive
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The check it replaced was inline `item?.isActive === false`, which **fails OPEN**: when the API
|
|
81
|
+
response omits `isActive`, `undefined === false` is `false` and the item reads as purchasable.
|
|
82
|
+
Whether the field arrives at all depends on it being listed in that tenant's `FIELDS` config — so
|
|
83
|
+
the old check silently stopped protecting anything the moment a config dropped the field.
|
|
84
|
+
`ACTIVE_VALUES` accepts the boolean, numeric and string forms so a serializer change cannot hide a
|
|
85
|
+
live item either.
|
|
86
|
+
|
|
87
|
+
### 3. The store is the choke point, not the buttons
|
|
88
|
+
|
|
89
|
+
There are **five** add-to-cart call sites. Rather than guard each, the check lives inside
|
|
90
|
+
`useCartStoreZu.addItem` — every path funnels through it, so a new button cannot be added without
|
|
91
|
+
inheriting the guard.
|
|
92
|
+
|
|
93
|
+
### 4. Reconciliation re-checks the persisted cart
|
|
94
|
+
|
|
95
|
+
The cart is a persisted Zustand store, so a line can outlive the item's deactivation.
|
|
96
|
+
`reconcileWithCatalog()` re-reads the live catalog and drops lines that are no longer purchasable.
|
|
97
|
+
It runs on **boot**, on **cart-overlay open**, and **pre-checkout** (`CartPage.tsx`).
|
|
98
|
+
|
|
99
|
+
### 5. Server-side PRE interceptor (records id 14, `sales-orders`)
|
|
100
|
+
|
|
101
|
+
`_Model_Compass_SalesOrder::prePost` / `::prePut` both call
|
|
102
|
+
`checkAllStandaloneItemsAreActive()`, which collects standalone item uuids from the payload, runs
|
|
103
|
+
**one** `SELECT Items.partNumber … WHERE Items.uuid IN (…) AND Items.isActive = 0`, and throws
|
|
104
|
+
`_Exception_Validation` naming the offending part numbers. Reaching that throw means a front-end
|
|
105
|
+
guard leaked, so it also `error_log`s.
|
|
106
|
+
|
|
107
|
+
Registered by
|
|
108
|
+
`dbchanges2/Client_{Compass,CompassCanada}/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql`
|
|
109
|
+
as `(apiId NULL, recordId 14, PRE, POST)` and `(apiId NULL, recordId 14, PRE, PUT)`.
|
|
110
|
+
|
|
111
|
+
## Scoping — by app slug and by FIELDS flag, never by client name
|
|
112
|
+
|
|
113
|
+
- **Server:** the check returns early unless `$api->app->slug === 'commerce'`. Supply, NetSuite,
|
|
114
|
+
EDI and admin corrections must still be able to write lines referencing deactivated items.
|
|
115
|
+
It uses the **slug**, not `Apps.id`, because **only `Core.Records` and `Core.RecordFields` carry
|
|
116
|
+
team-maintained ids** — `Apps.id` differs per environment, so an id literal would silently scope
|
|
117
|
+
to the wrong app somewhere.
|
|
118
|
+
- **Front end:** the new list-form item fetch and the unavailable panel are gated on the
|
|
119
|
+
**`getItemsByPersona.isEnabled` FIELDS flag**, not on a client name. QUAD is the only tenant with
|
|
120
|
+
it enabled, and QUAD keeps the old single-uuid persona-filtered path **byte-for-byte unchanged**.
|
|
121
|
+
A future tenant opts in by *not* enabling that flag, with no code change.
|
|
122
|
+
|
|
123
|
+
## Editing a placed order KEEPS an already-inactive line
|
|
124
|
+
|
|
125
|
+
Only a **newly added** inactive item is refused. An order that already contains a line whose item
|
|
126
|
+
has since been deactivated must still be editable.
|
|
127
|
+
|
|
128
|
+
**The trap this avoids is data loss, not a bad error message.** The submit path rebuilds its
|
|
129
|
+
payload from the cart, and `processItems(…, getDeleteItems = true)` treats **any existing order
|
|
130
|
+
line missing from that list as deleted** and issues `deleteData('sales-order-items', uuid)`. So if
|
|
131
|
+
the edit-order rebuild had simply refused to re-add the inactive line, the first time an admin
|
|
132
|
+
saved any edit — even a quantity change on a different line — that line would have been
|
|
133
|
+
**permanently deleted from a placed order**.
|
|
134
|
+
|
|
135
|
+
Implementation:
|
|
136
|
+
|
|
137
|
+
- **`restoreExistingOrderLine`** — a store action used *only* by the edit-order rebuild
|
|
138
|
+
(`syncSalesOrderFromApiToLocalStorage.ts`). It bypasses the purchasability check, shows no
|
|
139
|
+
notice, and does not duplicate.
|
|
140
|
+
- **`reconcileWithCatalog` no-ops while `inEditMode`**, so reconciliation cannot strip the line
|
|
141
|
+
either.
|
|
142
|
+
- **No `prePut` interceptor is registered on records id 15 (`sales-order-items`)** — deliberately.
|
|
143
|
+
A record-15 guard would block quantity edits on pre-existing inactive lines. The flat
|
|
144
|
+
`PUT /sales-order-items/{uuid}` used for existing lines is therefore not covered by the
|
|
145
|
+
record-14 interceptor at all, and that is the intended design, not an oversight.
|
|
146
|
+
|
|
147
|
+
## Gotchas / known issues
|
|
148
|
+
|
|
149
|
+
- **⚠ `Items.isActive = 0` means two different things and nothing distinguishes them.** Never gate
|
|
150
|
+
on the flag alone. Only a **standalone** line is judged; the kit-membership check
|
|
151
|
+
(`bundleItem` present / absent) is what makes the flag safe to read.
|
|
152
|
+
- **⚠ A failed lookup must never empty a cart.** `fetchItemsActiveStatus` originally returned `[]`
|
|
153
|
+
when `data.items` was missing or not an array. Reconciliation reads an empty list as *"every item
|
|
154
|
+
I asked about is gone"* and clears the cart — so a malformed `200` walked straight past the
|
|
155
|
+
`try`/`catch` that was supposed to guarantee a lookup failure was harmless. It now **throws**, so
|
|
156
|
+
the caller's `catch` leaves the cart untouched; a genuine zero-row answer still returns a real
|
|
157
|
+
empty array. Any "reconcile against the server" routine needs this distinction between *empty*
|
|
158
|
+
and *unknown*.
|
|
159
|
+
- **⚠ Judge only the uuids you actually queried.** The reconcile `set` filtered the *current*
|
|
160
|
+
`state.items` against results computed from a snapshot taken **before** the `await`, so a line
|
|
161
|
+
added while the lookup was in flight was removed without ever being checked. Fixed by scoping the
|
|
162
|
+
removal to the queried uuid set.
|
|
163
|
+
- **⚠ Remove-then-add is a data-loss ordering bug once an add can fail.** Splitting an item out of
|
|
164
|
+
a kit used to remove it from the bundle *before* adding it as a standalone line. With the guard
|
|
165
|
+
in place, a refused add would have made the item vanish entirely. The add is now attempted first
|
|
166
|
+
and the kit removal happens **only on success** (`CartTableBundleItem.tsx`). Audit every
|
|
167
|
+
"move between containers" flow when you introduce a rejection path.
|
|
168
|
+
- **⚠ `GET /items/{uuid}` silently ignores `where`.** The `isActive` filter only applies on the
|
|
169
|
+
**list** route. See
|
|
170
|
+
[V2 REST query contract](../../api2/features/v2-rest-query-contract.md#-a-where-clause-on-a-single-uuid-read-is-silently-ignored).
|
|
171
|
+
- **⚠ The 24h persisted React Query cache hides catalogue state in both directions** — a user who
|
|
172
|
+
had already opened an item kept seeing a working product page after it was switched off. That is
|
|
173
|
+
why the item-detail queries are `staleTime: 0` + `gcTime: 0` **and** excluded from persistence.
|
|
174
|
+
See [catalog cache freshness](./catalog-cache-freshness.md).
|
|
175
|
+
- **The server check is one query per order, not per line** — uuids are deduped into a single
|
|
176
|
+
`IN (…)` lookup, and the empty list returns early because `IN ()` is a MySQL syntax error.
|
|
177
|
+
|
|
178
|
+
## Change history
|
|
179
|
+
|
|
180
|
+
- 2026-08-14 — TRUE-80489: built the layered gate stopping an `Items.isActive = 0` item being
|
|
181
|
+
viewed, carted or ordered as a **standalone** line, with kit lines exempt. Recorded the measured
|
|
182
|
+
reason the flag alone is unusable (84 of 85 active Compass bundles contain an inactive component;
|
|
183
|
+
87 inactive vs 4 active PRIMARY components) and the `bundleItem` context discriminator. Layers:
|
|
184
|
+
item-page unavailable panel; one fail-closed `checkIsItemPurchasable` helper replacing an inline
|
|
185
|
+
`isActive === false` that failed **open** whenever FIELDS omitted the field; a guard inside
|
|
186
|
+
`useCartStoreZu.addItem` (all five add call sites funnel through it); catalog reconciliation on
|
|
187
|
+
boot / overlay-open / pre-checkout; and `_Model_Compass_SalesOrder::prePost`/`::prePut` behind
|
|
188
|
+
`(recordId 14, PRE, POST|PUT)` rows in both Compass tenants. Scoped by `$api->app->slug ===
|
|
189
|
+
'commerce'` (slug, not the per-environment `Apps.id`) and by the `getItemsByPersona.isEnabled`
|
|
190
|
+
FIELDS flag, leaving QUAD unchanged. Decided that **editing a placed order keeps an
|
|
191
|
+
already-inactive line** — `processItems(getDeleteItems = true)` deletes any existing line absent
|
|
192
|
+
from the rebuilt cart, so refusing it would permanently delete the line — via
|
|
193
|
+
`restoreExistingOrderLine` plus `reconcileWithCatalog` no-opping in edit mode, and deliberately
|
|
194
|
+
registered **no** record-15 `prePut`. Fixed two PR-review defects: `fetchItemsActiveStatus`
|
|
195
|
+
returning `[]` on a malformed response (cart-clearing; now throws) and a race that removed lines
|
|
196
|
+
added during the in-flight lookup. Reordered the kit-split so the add precedes the bundle
|
|
197
|
+
removal. Shipped on TRUE-80489, merged to `_beta` and `_sandbox-dev`, verified on beta. (bala)
|
|
@@ -6,7 +6,7 @@ project: TOGa Commerce
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-14
|
|
10
10
|
owners: ["bala"]
|
|
11
11
|
files:
|
|
12
12
|
- toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts
|
|
@@ -14,8 +14,10 @@ files:
|
|
|
14
14
|
- toga2-commerce/src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts
|
|
15
15
|
related:
|
|
16
16
|
- ./cart-bundle-submission-and-identity.md
|
|
17
|
+
- ./inactive-item-purchase-gating.md
|
|
17
18
|
- ../architecture.md
|
|
18
19
|
- ../../api2/features/v2-rest-query-contract.md
|
|
20
|
+
- ../../api2/features/api-payload-interceptors.md
|
|
19
21
|
- ../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
|
|
20
22
|
---
|
|
21
23
|
|
|
@@ -42,6 +44,35 @@ does not need child data must also send an explicit `depth: 1`.
|
|
|
42
44
|
`updateSalesOrderEmails()` runs **first** inside `syncSalesOrderData()`, so it is the earliest
|
|
43
45
|
header request on the wire and the one that collides with the delete burst.
|
|
44
46
|
|
|
47
|
+
## ⚠ Lines are NEVER sent nested on the order write — they go one request at a time
|
|
48
|
+
|
|
49
|
+
This is the single most misread thing about this path. **Commerce does not post an order with its
|
|
50
|
+
`salesOrderItems` inside it.**
|
|
51
|
+
|
|
52
|
+
- `syncSalesOrdersDataFromLocalStorageCartToApi.ts` **line 20 `delete`s `salesOrderItems`** from the
|
|
53
|
+
edit `PUT` payload outright.
|
|
54
|
+
- `syncSalesOrderItemsFromLocalStorageCartToApi.ts` then writes lines **individually**:
|
|
55
|
+
|
|
56
|
+
| Line state | Request | `Core.Records` id |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| new | `POST /v2/sales-orders/{uuid}/sales-order-items` (nested route, body **is** the line) | resolves **14** first |
|
|
59
|
+
| existing | `PUT /v2/sales-order-items/{uuid}` (flat) | **15** |
|
|
60
|
+
| removed | `DELETE /v2/sales-order-items/{uuid}` | **15** |
|
|
61
|
+
|
|
62
|
+
**Consequences worth internalising:**
|
|
63
|
+
|
|
64
|
+
- **A server-side guard on the order must not look for `salesOrderItems`.** A `(recordId 14, PRE,
|
|
65
|
+
POST)` interceptor *does* fire on the nested per-item POST — `SalesOrders` is the first-resolved
|
|
66
|
+
route pair — but it receives a **single line object with no wrapper**. A collector that only reads
|
|
67
|
+
`$payload->salesOrderItems` silently sees nothing. Details in
|
|
68
|
+
[API payload interceptors](../../api2/features/api-payload-interceptors.md#a-pre-interceptor-on-a-parent-record-also-fires-on-the-nested-child-post--with-the-child-as-payload).
|
|
69
|
+
- **The flat `PUT` is a different record (15)** and is not reachable from a record-14 hook at all.
|
|
70
|
+
- **`processItems(…, getDeleteItems = true)` treats "absent from the rebuilt cart" as DELETE.** Any
|
|
71
|
+
existing order line that the cart does not re-emit is deleted from a placed order on the next
|
|
72
|
+
save. So a rebuild path that *skips* a line — for any reason, including refusing it — destroys it.
|
|
73
|
+
Worked example: [inactive-item purchase
|
|
74
|
+
gating](./inactive-item-purchase-gating.md#editing-a-placed-order-keeps-an-already-inactive-line).
|
|
75
|
+
|
|
45
76
|
## ⚠ The race — concurrent deletes vs. a depth-3 response
|
|
46
77
|
|
|
47
78
|
On `_production`, `useSubmitOrder` ran:
|
|
@@ -100,9 +131,22 @@ Three changes, in `toga2-commerce`:
|
|
|
100
131
|
sync call actually changes anything before assuming it must stay.
|
|
101
132
|
- **The failure is a plain `EO-1` to the user** — the real exception only exists in `Logs.Issue`.
|
|
102
133
|
Resolve `error.id` (`<Issue.reference>-<Event.eventNumber>`) rather than reading the response.
|
|
134
|
+
- **⚠ Omitting a line from the rebuilt cart DELETES it from a placed order.** `processItems` with
|
|
135
|
+
`getDeleteItems = true` derives the delete set by difference, so "don't re-add this one" and
|
|
136
|
+
"destroy this one" are the same instruction on this path.
|
|
103
137
|
|
|
104
138
|
## Change history
|
|
105
139
|
|
|
140
|
+
- 2026-08-14 — Recorded **how order lines are actually written**, learned while adding a
|
|
141
|
+
server-side order guard: the header sync `delete`s `salesOrderItems` from the edit `PUT` (line
|
|
142
|
+
20), and lines are written **one request at a time** — new lines via the nested
|
|
143
|
+
`POST /sales-orders/{uuid}/sales-order-items` (which resolves `SalesOrders`, records id **14**,
|
|
144
|
+
first, and hands a PRE interceptor a **single line object with no `salesOrderItems` wrapper**),
|
|
145
|
+
existing lines via the flat `PUT /sales-order-items/{uuid}` (records id **15**, unreachable from a
|
|
146
|
+
record-14 hook). Also recorded that `processItems(…, getDeleteItems = true)` derives deletions by
|
|
147
|
+
difference, so any existing line the rebuilt cart does not re-emit is **permanently deleted** on
|
|
148
|
+
the next save of a placed order. (bala)
|
|
149
|
+
|
|
106
150
|
- 2026-08-11 — Created from the production investigation of the checkout 500s: `useSubmitOrder`'s
|
|
107
151
|
`Promise.all([syncSalesOrderData(), syncSalesOrderLocalStorage()])` raced `updateSalesOrderEmails()`'s
|
|
108
152
|
default-depth-3 `PUT /v2/sales-order-email-addresses/{uuid}` (which walks
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
| [Column Visibility (URL-driven show/hide columns)](features/column-visibility.md) | A "Columns" header button that opens a modal listing every column from the table meta, lets the user show/hide columns, adjusts the table live, and persists the | toga25-supply/src/components/ColumnVisibilityModal/, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx |
|
|
10
10
|
| [Force Logout on Deployment (useDeploymentGuard)](features/force-logout-on-deployment.md) | On large deployments the backend bumps the Core parameter `META_LAST_REFRESH_DATETIME`. | toga25-supply/src/hooks/useDeploymentGuard.tsx, toga25-supply/src/App.tsx |
|
|
11
11
|
| [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
|
|
12
|
-
| [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/GenericNestedTables.tsx, toga25-supply/src/layout/GenericNestedTables/GenericTableLayout.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json, toga25-supply/src/hooks/useTableCellInteractions.ts, toga25-supply/src/hooks/useServerTableUrlState.ts |
|
|
12
|
+
| [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/GenericNestedTables.tsx, toga25-supply/src/layout/GenericNestedTables/GenericTableLayout.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json, toga25-supply/src/hooks/useTableCellInteractions.ts, toga25-supply/src/hooks/useServerTableUrlState.ts, toga25-supply/src/layout/RecordApprovalModal/helpers/handleFormatApprovalWorkflowPayload.ts, toga25-supply/src/layout/RecordApprovalModal/api/approvalDecisionsApi.ts |
|
|
13
13
|
| [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/surface/applyColSpan.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/sectionRenderers.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getVisibleSections.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderApprovalSummaryGrid.tsx, toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderNotesSection.tsx, toga25-supply/src/pages/SalesOrders/helpers/cleanOrder.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/resolveElementState.ts, toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrderRowRecordState.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SurfaceRowActions.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/surfaceBundleToItemFields.ts, toga25-supply/src/layout/ItemRecordModalLayout/helpers/index.ts, toga25-supply/src/layout/ItemRecordModalLayout/viewModel/useItemRecordModalViewModel.tsx, toga25-supply/src/layout/ItemRecordModalLayout/ItemRecordModalLayout.tsx, toga25-supply/src/layout/ItemRecordModalLayout/components/ItemRecordView.tsx, toga25-supply/src/surface/useStatusColors.ts, toga25-supply/src/surface/SurfaceHeader.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/helpers/surfaceBundlesToDecisionFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/useApprovalModalViewModel.tsx |
|
|
14
14
|
| [AWS Amplify Multi-Environment Deployment](workflows/amplify-deployment.md) | How `toga25-supply` deploys to **all** of its environments on AWS Amplify from a **single shared `amplify.yml`**. | toga25-supply/amplify.yml, toga25-supply/src/api/api.ts, toga25-supply/src/hooks/useAuthenticationFlow.ts, toga25-supply/vite.config.ts, toga25-supply/package.json |
|
|
15
15
|
| [Cypress Testing Harness (component + e2e)](workflows/cypress-testing.md) | The Cypress test harness for the `toga25-supply` frontend, bootstrapped from scratch (`cypress` was already a dependency but there was no config, no `cypress/` | toga25-supply/cypress.config.ts, toga25-supply/cypress/tsconfig.json, toga25-supply/cypress/support/component.tsx, toga25-supply/cypress/support/component-index.html, toga25-supply/cypress/support/e2e.ts, toga25-supply/cypress/support/commands.ts, toga25-supply/cypress/support/fixtures.ts, toga25-supply/cypress/support/mocks/useApprovalModalViewModel.ts, toga25-supply/cypress/component/SalesOrderApprovalModalsLayout.cy.tsx, toga25-supply/cypress/component/RecordApprovalModalLayout.cy.tsx, toga25-supply/cypress/component/EnterPoNumberModal.cy.tsx, toga25-supply/cypress/e2e/salesOrderApproval.cy.ts |
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-08-14
|
|
10
10
|
owners: [apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/layout/ItemRecordModalLayout/
|
|
@@ -19,6 +19,8 @@ files:
|
|
|
19
19
|
- toga25-supply/src/pages/Inventory/viewModel/FIELDS/DEFAULT/inventoryGroupings.json
|
|
20
20
|
- toga25-supply/src/hooks/useTableCellInteractions.ts
|
|
21
21
|
- toga25-supply/src/hooks/useServerTableUrlState.ts
|
|
22
|
+
- toga25-supply/src/layout/RecordApprovalModal/helpers/handleFormatApprovalWorkflowPayload.ts
|
|
23
|
+
- toga25-supply/src/layout/RecordApprovalModal/api/approvalDecisionsApi.ts
|
|
22
24
|
related:
|
|
23
25
|
- ../architecture.md
|
|
24
26
|
- meta-driven-table-data.md
|
|
@@ -189,8 +191,25 @@ Scaffold per the `ItemRecordModalLayout` pattern (`src/layout/ItemRecordModalLay
|
|
|
189
191
|
for stages whose `note` / `assignedTo` / `decision` actually changed, and chooses verb by
|
|
190
192
|
`stage.ApprovalDecisions.uuid` — `null` → POST `/approval-decisions`, otherwise PUT
|
|
191
193
|
`/approval-decisions/{uuid}`.
|
|
194
|
+
- **Clearing a stage's assignee is a DELETE, not a dropped field.** The assignee is stored
|
|
195
|
+
**on** the `ApprovalDecisions` record, so to *unassign* a pending stage you must emit a
|
|
196
|
+
**DELETE `/approval-decisions/{uuid}`** — not a PUT with an empty value. The earlier bug: the
|
|
197
|
+
payload builder only added the assignee field when the new value was truthy
|
|
198
|
+
(`if (didAssignedToChanged && formAssignedToUuid)`), so clearing it produced **no** backend
|
|
199
|
+
change and the old assignment survived. Fix (`handleFormatApprovalWorkflowPayload`): emit a
|
|
200
|
+
DELETE item **only** when all three hold — the assignee actually changed to empty, the stage
|
|
201
|
+
has **no decision yet** (`isApproved` null/undefined; a decided/denied/approved or pre-approved
|
|
202
|
+
stage is excluded), and an existing `ApprovalDecisions.uuid` exists to delete.
|
|
203
|
+
`updateApprovalWorkflow` (`approvalDecisionsApi.ts`) gained a `method === "DELETE"` branch
|
|
204
|
+
calling `apiDelete(route)`.
|
|
192
205
|
|
|
193
206
|
## Change history
|
|
207
|
+
- 2026-08-14 — Fixed unassigning an approval-workflow stage: clearing the assignee now emits a
|
|
208
|
+
DELETE `/approval-decisions/{uuid}` (the assignment lives on the `ApprovalDecisions` record)
|
|
209
|
+
instead of silently dropping the field. Gated to fire only for a still-undecided stage
|
|
210
|
+
(`isApproved` null) with an existing decision uuid; `updateApprovalWorkflow` gained a
|
|
211
|
+
`DELETE` branch (`apiDelete`). Added the gotcha + the two RecordApprovalModal files.
|
|
212
|
+
(apeterson)
|
|
194
213
|
- 2026-07-20 — Pattern 5: added `buildSwapTriggerColumns(triggerSwap)` and `getModalUuid(row)` to
|
|
195
214
|
`TableLevel` so a swap level's "drill to child tier" (a dedicated "See units" chevron) and
|
|
196
215
|
"open this record" (plain row click → `renderModal`) are decoupled, and a record modal can open a
|
package/knowledge/INDEX.md
CHANGED
|
@@ -29,7 +29,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
29
29
|
- **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
|
|
30
30
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
31
31
|
- **ai-bdr** (AI-BDR) — 9 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
32
|
-
- **toga2-commerce** (TOGa Commerce) —
|
|
32
|
+
- **toga2-commerce** (TOGa Commerce) — 15 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
33
33
|
- **toga25-supply** (TOGa 2.5 Supply) — 11 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
34
34
|
- **toga-blox** (TOGa Blox) — 8 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
|
35
35
|
- **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
|
package/package.json
CHANGED