toga-ai 1.0.929 → 1.0.930
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/features/v2-api-error-codes.md +19 -1
- package/knowledge/2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md +32 -1
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga2-commerce/features/catalog-cache-freshness.md +57 -9
- package/knowledge/2.0/apps/toga25-supply/features/bundle-item-pricing.md +51 -9
- package/knowledge/2.0/apps/toga25-supply/workflows/amplify-deployment.md +31 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: [mhammontree, tcox, jcardinal, ajean, bala, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -86,6 +86,16 @@ The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** i
|
|
|
86
86
|
15. **A `4xx` validation (`EV-*`) response still leaks the raw failing SQL + DB name (pre-existing, UNFIXED as of 2026-08-13).** Distinct from the 2026-07-30 fix that gated the 500 `message`/`trace` behind `isDebugMode()`: on a DB error surfaced as a **validation** response, the `EV-10` body returned to the **external** caller still contains the raw failing SQL statement **and the DB name** (observed in Office Depot's 400 body on the ASN deadlock path). Worth scrubbing independently. Also **why deadlock detection was built on the MySQL errno** (1213/1205), not on matching the response text — see [V2 deadlock-retry](./v2-deadlock-retry.md).
|
|
87
87
|
16. **A NOT NULL column that is NOT a writable V2 field must be DEFAULTED in the ORM — never sent in the payload.** Adding the key to the POST body throws **`EV-8`** ("field does not exist") because the column has no writable-field registration; but leaving it out throws the DB NOT NULL error on insert. The fix is neither — declare a model default so the ORM writes it without a payload key, e.g. `public $isActive = [self::FIELD_BOOLEAN, self::FIELDOPT_DEFAULT => true];` on `_Model_Client_TransferOrder` (2026-08-27, NYCHH transfer-order POST). Contrast with a NOT NULL column that *should* come from the caller/upstream (that is `EV-10` — resolve the value, don't default it).
|
|
88
88
|
|
|
89
|
+
17. **⚠ `status: 200` + `isSuccess: true` does NOT prove the write landed — check `messages[]`.**
|
|
90
|
+
A DB failure can be attached as an **ERROR message** while the envelope still reports 200/success,
|
|
91
|
+
with the real cause buried in **`messages[].identifiers.error`**. Observed 2026-09-30 on a bundle
|
|
92
|
+
item price save: an `EV-10` *"Column 'currencyId' cannot be null"* rode inside a 200 response.
|
|
93
|
+
Because the HTTP status is 2xx, axios does not throw and blox `apiPut` resolves — so the SPA ran
|
|
94
|
+
its success path and told the user "Bundle item updated" on a write that never happened. **Every
|
|
95
|
+
client must scan `messages[]` for `type: ERROR` and fail on it**, not branch on `isSuccess`/status
|
|
96
|
+
alone. (`isSuccess` is only `status` in the 2xx range; it is not a write receipt.) Background:
|
|
97
|
+
[bundle item pricing](../../toga25-supply/features/bundle-item-pricing.md).
|
|
98
|
+
|
|
89
99
|
### BREAKING (2026-07-30): `error` is now an object, not a bare string
|
|
90
100
|
|
|
91
101
|
The V2 envelope's `error` changed from a bare string (`"EO-1"`) to an **object**:
|
|
@@ -132,9 +142,17 @@ This applies to `api2/Controller/Index.php` and to any `catch (_Exception_Valida
|
|
|
132
142
|
- **Always send an explicit `fields=` list** so a denied field surfaces as `EZ-2` instead of silent omission — but note a `_`-prefixed calculated field named in `fields=` is never ACL-checked (see [V2 REST query contract](v2-rest-query-contract.md)).
|
|
133
143
|
- **`error` is an object `{code, id}`, not a bare string** (since 2026-07-30) — `id` is always present (null on a graceful rejection); do not regex-sweep `.error` → `.error?.code` (local `{success,error}` shapes are not envelopes). OAuth keeps the flat string form deliberately.
|
|
134
144
|
- **Test `_Exception_Business` before `_Exception_Validation` in every catch chain** — business now extends validation; wrong order silently stops capturing business exceptions.
|
|
145
|
+
- **⚠ 200 + `isSuccess: true` is not a write receipt** — a DB failure can ride along as an ERROR entry
|
|
146
|
+
in `messages[]` (cause in `messages[].identifiers.error`). Scan `messages[]` for `type: ERROR`; a
|
|
147
|
+
front end that trusts the status shows a false "saved".
|
|
135
148
|
- **A 4xx validation body still leaks the raw failing SQL + DB name** to external callers (unfixed) — scrub independently.
|
|
136
149
|
- **`c_` prefix is significant** — a mis-prefixed field name rejects the whole write with EV-12; verify native-vs-custom with `SHOW COLUMNS`.
|
|
137
150
|
|
|
151
|
+
## Change history
|
|
152
|
+
- 2026-10-01 — Added note 17: a 200 / `isSuccess: true` envelope can still carry an ERROR entry in
|
|
153
|
+
`messages[]` (DB cause in `messages[].identifiers.error`), so axios/blox never throws and a front end
|
|
154
|
+
reports a false "saved" (`EV-10` on a bundle item price save). (apeterson)
|
|
155
|
+
|
|
138
156
|
## Related
|
|
139
157
|
|
|
140
158
|
- [Add a writable field to a V2 record (3-file migration) + expose a scripted API](../../_underscore/features/acl-permission-chain.md)
|
|
@@ -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-10-01
|
|
10
10
|
owners: [bala, kyalamarthi, apeterson, ajean, jcardinal, rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
|
|
@@ -17,6 +17,7 @@ files:
|
|
|
17
17
|
- dbchanges2/Client_Compass/2026-09-18a - BackfillUpstreamItemFulfillmentTracking.sql
|
|
18
18
|
- dbchanges2/Client_Compass/2026-09-21a - BackfillUpstreamItemFulfillmentUnits.sql
|
|
19
19
|
- dbchanges2/Client_Compass/2026-09-23a - BundlesTableViewFieldFlags.sql
|
|
20
|
+
- dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql
|
|
20
21
|
related:
|
|
21
22
|
- ../architecture.md
|
|
22
23
|
- ../../worker2/features/netsuite-salesorder-open-orders-sync.md
|
|
@@ -318,6 +319,29 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
|
318
319
|
no-email straggler does not abort the ALTER for every other tenant. Wrap the backfill in
|
|
319
320
|
`START TRANSACTION … COMMIT` and put the ALTER **after** the COMMIT.
|
|
320
321
|
|
|
322
|
+
### ⚠ A column DEFAULT does NOT protect a `_Model`-written column
|
|
323
|
+
|
|
324
|
+
Making an existing column `NOT NULL DEFAULT <value>` reads as self-healing — "writers that never send
|
|
325
|
+
the column get the default". **That is false for any column declared on a `_Model`.** `_Model` writes
|
|
326
|
+
**every** declared field on INSERT, so it emits `currencyId = NULL` explicitly, and an explicit NULL
|
|
327
|
+
**never** falls back to a column DEFAULT. The insert dies `Error 1048: Column 'x' cannot be null`.
|
|
328
|
+
|
|
329
|
+
> Before adding `NOT NULL` to a column, grep the `_underscore` model for the field. If it is declared,
|
|
330
|
+
> the migration needs a **writer** that sets it — a model default
|
|
331
|
+
> (`public $currencyId = [self::FIELD_FOREIGNKEY, self::FIELDOPT_DEFAULT => …]`) or a payload value.
|
|
332
|
+
> A DEFAULT clause alone only helps hand-written SQL that omits the column.
|
|
333
|
+
|
|
334
|
+
And you usually cannot just leave the column nullable: a unique key over a nullable column enforces
|
|
335
|
+
nothing, because MySQL treats every NULL as distinct — so a dedupe that adds
|
|
336
|
+
`UNIQUE (a, b, currencyId)` is only real once `currencyId` is NOT NULL **and** populated by the ORM.
|
|
337
|
+
Ship the model default in the **same** change as the ALTER, never after it.
|
|
338
|
+
|
|
339
|
+
Live break: `Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql` took down every bundle
|
|
340
|
+
item price insert on client-sandbox — see
|
|
341
|
+
[bundle item pricing](../../toga25-supply/features/bundle-item-pricing.md). Related, from the other
|
|
342
|
+
direction: a NOT NULL column that is not a writable V2 field must be defaulted in the ORM, never sent
|
|
343
|
+
in the payload ([V2 error codes](../../api2/features/v2-api-error-codes.md), note 16).
|
|
344
|
+
|
|
321
345
|
### ⚠ latin1 schema drift breaks a forced COLLATE in a fan-out file
|
|
322
346
|
|
|
323
347
|
Some tenants' `uuid`/text columns are `latin1`, not utf8mb4 (verified Nychh, Spglobal). So a forced
|
|
@@ -451,6 +475,8 @@ an INSERT that names only `uuid`/`tableViewId`/`recordFieldId`/`isVisible`/`inde
|
|
|
451
475
|
|
|
452
476
|
## Gotchas
|
|
453
477
|
|
|
478
|
+
- **⚠ `NOT NULL DEFAULT <value>` does not save a `_Model` column** — the ORM writes an explicit NULL,
|
|
479
|
+
which skips the DEFAULT and errors 1048. Ship a model default with the ALTER.
|
|
454
480
|
- **⚠ `col = 0` never matches a NULL** — guard flag-enabling UPDATEs with `COALESCE(col, 0) = 0`, or the migration reports success and changes nothing.
|
|
455
481
|
- **⚠ An unguarded ACL grant migration duplicates silently — `AclRecordPermissions` has no unique key
|
|
456
482
|
that stops it (found in prod 2026-09-15).** `_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql`
|
|
@@ -514,6 +540,11 @@ an INSERT that names only `uuid`/`tableViewId`/`recordFieldId`/`isVisible`/`inde
|
|
|
514
540
|
[FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
|
|
515
541
|
|
|
516
542
|
## Change history
|
|
543
|
+
- 2026-10-01 — Added the `_Model` + column-DEFAULT trap: `_Model` writes every declared field, so an
|
|
544
|
+
explicit NULL skips a `NOT NULL DEFAULT` clause and the insert dies 1048; a unique key over a
|
|
545
|
+
nullable column enforces nothing, so the model default must ship with the ALTER
|
|
546
|
+
(`Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql` broke every bundle item price
|
|
547
|
+
insert). (apeterson)
|
|
517
548
|
- 2026-09-28 — `Users_Personas` / `Users_Roles` added to the composite-unique table (both unique on the id pair, so a guarded re-run is a clean no-op), plus the counterpoint to the dtCreated rule: these user bridges have no timestamp column at all. (apeterson)
|
|
518
549
|
- 2026-09-23 — Added the NULL-unsafe flag-guard trap (`col = 0` matches no NULL row; use `COALESCE(col, 0) = 0`), found when a bundles TableViewFields migration updated 0 rows; flagged the 2026-08-26 `EnableTableViewFieldFilters` files as carrying the same hole. (apeterson)
|
|
519
550
|
- 2026-09-22 — Added multi-tier chain backfill rules from the Compass tracking/serial backfills: re-run the same file until the verify count is 0 (each pass moves data up one tier; both files needed 3 passes); `GROUP BY` the target key pair instead of `SELECT DISTINCT` when many downstream rows roll up to one upstream row (bundles tripped a bridge UNIQUE 6-to-1); measure the safe/ambiguous/no-match split before writing bulk DML and skip the ambiguous rows (4,900 broken links = 1,982 safe + 0 ambiguous + 2,918 no-match). Sharpened the unique-key rule with measured composite keys across the four item-fulfillment tables — `ItemFulfillmentItemUnits` has none and already carries 254 duplicates. (rgirish)
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
| [Cart Notification Emails — auto-add/remove lifecycle + duplicate prevention](features/cart-notification-emails.md) | Cart Notifications section: CC-email duplicate prevention + auto-add/remove of order-for-user & delegate-manager emails; open when touching notification emails |
|
|
9
9
|
| [Cart Order-Total & Shipping Computation](features/cart-order-total-computation.md) | How the cart total (subtotal/shipping/tax/total) is computed and where shipping cost flows from; open when a cart total or shipping charge looks wrong. |
|
|
10
10
|
| [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | Current state of the Cart form (field-config-driven, section-hardcoded, two dual-mode forms) plus the DECIDED-but-unbuilt config-driven refactor to the toga2.5 |
|
|
11
|
-
| [Catalog cache freshness — the
|
|
11
|
+
| [Catalog cache freshness — the persisted query cache and the per-query staleTime overrides](features/catalog-cache-freshness.md) | The persisted React Query cache (`localStorage["commerce"]`) plus the per-query `staleTime` overrides that still serve prices up to 24h old, how to opt a query |
|
|
12
12
|
| [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | Storefront category tile order is one DB column (`AssortmentItems.sortOrder` ASC) with a NULL-sorts-first trap; open when asked to reorder items on a category p |
|
|
13
13
|
| [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Per-tenant/language/role JSON config that drives almost all commerce UI text, fields, and login fetches (Layer A global + Layer B FIELDS registry); open before |
|
|
14
14
|
| [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | Expedited shipping options are config-gated to show only when the cart holds a computer kit; open when touching cart shipping-method options or the gate config. |
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Catalog cache freshness — the
|
|
2
|
+
title: Catalog cache freshness — the persisted query cache and the per-query staleTime overrides
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: toga2-commerce
|
|
5
5
|
project: TOGa Commerce
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: ["bala", "apeterson", "tcox"]
|
|
11
11
|
files:
|
|
12
|
+
- toga2-commerce/src/lib/queryClient.ts
|
|
12
13
|
- toga2-commerce/src/App.tsx
|
|
13
14
|
- toga2-commerce/src/lib/queryCacheRestoration.ts
|
|
14
15
|
- toga2-commerce/src/contexts/AuthContext.tsx
|
|
@@ -18,32 +19,43 @@ files:
|
|
|
18
19
|
- toga2-commerce/src/pages/BundleView/hooks/useBundleQueryData.ts
|
|
19
20
|
- toga2-commerce/src/pages/Home/viewModel/useHomeViewModel.ts
|
|
20
21
|
- toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts
|
|
22
|
+
- toga2-commerce/src/pages/Filter/viewModel/useFilterViewModel.ts
|
|
23
|
+
- toga2-commerce/src/pages/Cart/viewModel/useCartViewModel.ts
|
|
24
|
+
- toga2-commerce/src/pages/Cart/viewModel/useEditAddressFromCartForm.ts
|
|
25
|
+
- toga2-commerce/src/queries/queries.ts
|
|
26
|
+
- toga2-commerce/src/hooks/useDeploymentGuard.tsx
|
|
21
27
|
related:
|
|
22
28
|
- ./inactive-item-purchase-gating.md
|
|
29
|
+
- ./force-logout-on-deployment.md
|
|
30
|
+
- ../../../standards/frontend.md
|
|
23
31
|
- ../architecture.md
|
|
24
32
|
- ../../toga25-supply/features/persisted-query-cache.md
|
|
25
33
|
- ../../toga25-supply/features/force-logout-on-deployment.md
|
|
26
34
|
---
|
|
27
35
|
|
|
28
|
-
The
|
|
36
|
+
The persisted React Query cache (`localStorage["commerce"]`) plus the per-query `staleTime` overrides that still serve prices up to 24h old, how to opt a query out, and the stacked causes behind "stale data / cache won't clear / stuck login"; open for any freshness or forced-logout bug.
|
|
29
37
|
|
|
30
38
|
## Summary
|
|
31
39
|
|
|
32
|
-
> **Status 2026-
|
|
40
|
+
> **Status 2026-10-01:** the **global** default is already `staleTime: 0` (causes 1, 2 and 3 fixed; cause 4 open). The remaining staleness is **per-query**: several price-bearing queries hardcode `staleTime: 24h`. See [Where the staleness lives today](#where-the-staleness-lives-today-2026-10-01) before changing any knob — setting the global `staleTime` to 0 is a **no-op**, it is already 0.
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
The whole React Query cache is persisted to **`localStorage["commerce"]`** via `PersistQueryClientProvider`. Paired with a 24h `staleTime` that serves **catalogue and price state up to 24 hours out of date, across full page reloads** — in **both directions**: after an item is switched off, a user who already viewed it keeps seeing the live product page with a working Add to Cart; after it is switched back on, it stays hidden. The 24h value was the **client default** until 2026-09-22; it now survives only as **per-query overrides**.
|
|
35
43
|
|
|
36
44
|
Any query whose answer **gates an action** (can this be bought? is this still available?) must be opted out of *both* mechanisms. Opting out of one is not enough.
|
|
37
45
|
|
|
38
|
-
**Three long-running production complaints all trace back to this file set**, pinned to `file:line` on 2026-09-03 (read-only, branch `TRUE-81610`): "users don't see updated data", "clearing their cache doesn't work", "users get stuck on a broken login page after a forced logout". They are **four separate causes** (below)
|
|
46
|
+
**Three long-running production complaints all trace back to this file set**, pinned to `file:line` on 2026-09-03 (read-only, branch `TRUE-81610`): "users don't see updated data", "clearing their cache doesn't work", "users get stuck on a broken login page after a forced logout". They are **four separate causes** (below); causes 1-3 are fixed, cause 4 is open.
|
|
39
47
|
|
|
40
48
|
## How it works
|
|
41
49
|
|
|
42
|
-
### The default:
|
|
50
|
+
### The default: fresh in memory, still persisted to disk
|
|
43
51
|
|
|
44
|
-
`
|
|
52
|
+
The `QueryClient` **no longer lives in `App.tsx`** — it moved to **`src/lib/queryClient.ts`**, which sets **`staleTime: 0`** and **`refetchOnWindowFocus: false`**. The cache is still persisted to `localStorage["commerce"]` via `createSyncStoragePersister`, now with a build-id **`buster`** on the persister.
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
> **⚠ The old "24h default `staleTime`" is gone — do not "fix" freshness by setting the global default to 0.** It already is 0. Any remaining 24h behaviour comes from **per-query overrides** (next section). Verified 2026-10-01; older docs and the [architecture doc](../architecture.md) still describe the 24h default.
|
|
55
|
+
|
|
56
|
+
A per-query `staleTime` always beats the client default, so a hook that hardcodes 24h is unaffected by the global value — including the `["clientFields", …]` query that carries the per-tenant `FIELDS` content, which is why copy/config deploys can still look stale for returning users.
|
|
57
|
+
|
|
58
|
+
**TOGa 2.5 Supply** reached the same default — `staleTime: 0` *because* the cache is persisted — and has the same per-hook override problem. See [supply's persisted query cache](../../toga25-supply/features/persisted-query-cache.md).
|
|
47
59
|
|
|
48
60
|
### The platform's cache-bust: the Core parameters blob
|
|
49
61
|
|
|
@@ -106,6 +118,38 @@ The persister is also set to **`throttleTime: 0`**: the 1000ms default meant a q
|
|
|
106
118
|
|
|
107
119
|
Separately, commerce's `login()` **never cleared the cache**, so signing in as a different user inherited the prior user's data. ✅ **That half is fixed 2026-09-22** — `resetClientState()` runs on **both** login and logout (see [SSO & session gating](./sso-redirect-and-session-gating.md)). The force-logout aggression and the history monkey-patch are **still open**.
|
|
108
120
|
|
|
121
|
+
### Where the staleness lives today (2026-10-01)
|
|
122
|
+
|
|
123
|
+
Inventory verified read-only on 2026-10-01. Every value below is a **per-query override** that wins over the global `staleTime: 0`.
|
|
124
|
+
|
|
125
|
+
| staleTime | Where |
|
|
126
|
+
|---|---|
|
|
127
|
+
| **24h** | `pages/ItemsView/viewModel/useItemDetailsViewModel.ts:76` (item detail) · `pages/Filter/viewModel/useFilterViewModel.ts:106,134` (filter/search results) · `pages/Cart/viewModel/useCartViewModel.ts:310` (shipping methods) · `pages/Cart/viewModel/useEditAddressFromCartForm.ts:75` |
|
|
128
|
+
| **5 min** | `queries/queries.ts:39,46,68,78` (login / prefetch) |
|
|
129
|
+
| **30s** (+ `gcTime: 1h`) | `pages/Home/viewModel/useHomeViewModel.ts:150,175,201` · `pages/BundleView/hooks/useBundleQueryData.ts:26,42` |
|
|
130
|
+
|
|
131
|
+
**Consequence — the answer to "why doesn't a price change show up":** a price or availability edit can take **up to 24 hours** to reach a shopper on the item page, and **a browser refresh does not fix it**, because the cache is persisted to `localStorage["commerce"]` and survives reload and browser restart.
|
|
132
|
+
|
|
133
|
+
### Planned change — ticket scope (agreed 2026-10-01, NOT implemented)
|
|
134
|
+
|
|
135
|
+
1. Remove the per-query `staleTime` overrides on item detail, filter/search results, cart shipping methods, Home and BundleView, so the global `0` applies.
|
|
136
|
+
2. Remove `refetchOnWindowFocus: false` from `src/lib/queryClient.ts`, so React Query's default `true` applies app-wide.
|
|
137
|
+
3. Then delete the now-redundant `refetchOnWindowFocus: true` (and its comment) at `src/hooks/useDeploymentGuard.tsx:93`.
|
|
138
|
+
|
|
139
|
+
Open in the ticket: whether `useEditAddressFromCartForm.ts:75` and the 5-minute `queries/queries.ts` queries are in scope. **`gcTime: 1h` on Home and BundleView is deliberately left alone** — `gcTime` controls how long unused data is kept, not when a query refetches.
|
|
140
|
+
|
|
141
|
+
### What "fresh" will mean after that change
|
|
142
|
+
|
|
143
|
+
A data change reaches the shopper on: **navigating to the page** (the query mounts), **refresh / fresh URL**, **the tab becoming visible again**, **network reconnect** (`refetchOnReconnect` defaults true), or a **mutation that invalidates the query**. Nothing polls, so it does **not** update while the shopper sits still on one page with the tab in front.
|
|
144
|
+
|
|
145
|
+
Side benefit: `AuthLayout`-owned queries (header, notifications, `FIELDS` copy) never remount, so today they only refresh on a full page load. Turning focus refetching on covers them — a partial answer to the "FIELDS copy is stale after a deploy" gotcha.
|
|
146
|
+
|
|
147
|
+
Three limits to state to the business up front:
|
|
148
|
+
|
|
149
|
+
1. **Prices already in the cart do NOT update.** The cart lives in Zustand + `localStorage`, not React Query.
|
|
150
|
+
2. **Alt-tabbing to another application and back is not a focus event** — see [frontend standard §4](../../../standards/frontend.md) and [force logout on deployment](./force-logout-on-deployment.md).
|
|
151
|
+
3. **On a cold load the old value paints first** from `localStorage["commerce"]` and swaps when the response lands.
|
|
152
|
+
|
|
109
153
|
### Decision — `gcTime` is the speed knob, `staleTime` is the correctness knob
|
|
110
154
|
|
|
111
155
|
Decided 2026-09-03. The 24h `staleTime` was reached for to make heavy item/bundle pages feel fast. **It is the wrong knob, and it buys zero perceived speed here.**
|
|
@@ -131,6 +175,9 @@ Keep the **app default at `0`**, like supply.
|
|
|
131
175
|
|
|
132
176
|
## Gotchas
|
|
133
177
|
|
|
178
|
+
- **⚠ Setting the global `staleTime` to 0 fixes nothing — it is already 0.** The 24h behaviour is per-query (`src/lib/queryClient.ts` holds the default; the overrides are listed above).
|
|
179
|
+
- **⚠ A price change can be 24h late on the item page and a refresh will not clear it** — the stale copy is in `localStorage["commerce"]`.
|
|
180
|
+
- **⚠ A cart line's price never refetches.** The cart is Zustand + `localStorage`, outside React Query; no `staleTime` change touches it.
|
|
134
181
|
- **⚠ Never verify a catalogue or FIELDS change on a warm browser.** Clear `localStorage.removeItem('commerce')` or log out first. A "it didn't deploy" report is usually a rehydrated cache.
|
|
135
182
|
- **⚠ `gcTime: 0` does not stop persistence.** Active queries are still dehydrated. Exclude the key via `dehydrateOptions.shouldDehydrateQuery` as well.
|
|
136
183
|
- **⚠ Nothing is excluded from persistence on `_production`** — the `shouldDehydrateQuery` allowlist lives only on `_beta`/`#sprint84`. Check the branch.
|
|
@@ -142,6 +189,7 @@ Keep the **app default at `0`**, like supply.
|
|
|
142
189
|
- **`AuthLayout` never remounts**, so `refetchOnMount` fires once per session for queries it owns — a route-change refresh has to be wired explicitly. That interacts with everything above: a query can be both stale *and* never refetched during a session.
|
|
143
190
|
|
|
144
191
|
## Change history
|
|
192
|
+
- 2026-10-01 — Corrected: the global default is already `staleTime: 0` (`QueryClient` moved to `src/lib/queryClient.ts`, `refetchOnWindowFocus: false`, persister `buster` added). Per-query 24h overrides inventoried at `file:line`; ticket scope and the post-change freshness promise (and its cart / alt-tab / first-paint limits) recorded. No code changed. (apeterson)
|
|
145
193
|
- 2026-09-22 — Causes 1 and 2 fixed: app default `staleTime: 0` + `refetchOnWindowFocus: false`, heavy hooks on 30s/1h, and all clears routed through `clearPersistedQueryCache()` with `throttleTime: 0`. Login now resets client state. TRUE-81991 (`e546db3d`). (apeterson)
|
|
146
194
|
- 2026-09-21 — Cause 3 (public-token auth gate) fixed on `TRUE-82172` and moved to its own doc; causes 1, 2, 4 still open. (tcox)
|
|
147
195
|
- 2026-09-03 — Four stacked causes pinned to `file:line`; `gcTime`-vs-`staleTime` decision recorded. (bala, apeterson)
|
|
@@ -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-10-01
|
|
10
10
|
owners: ["apeterson"]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/layout/BundleItemRecordModalLayout/hooks/useBundleItemEditForm.tsx
|
|
@@ -14,10 +14,14 @@ files:
|
|
|
14
14
|
- toga25-supply/src/layout/BundleRecordModalLayout/viewModel/FIELDS/DEFAULT/bundleRecordViewFields.json
|
|
15
15
|
- toga25-supply/src/layout/BundleRecordModalLayout/components/BundleItemsTable.tsx
|
|
16
16
|
- dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql
|
|
17
|
+
- _underscore/Model/Client/BundleItem.php
|
|
18
|
+
- _underscore/Model/Client/BundleItemPrice.php
|
|
17
19
|
related:
|
|
18
20
|
- ../../api2/features/nested-relationship-writes.md
|
|
19
21
|
- ../../api2/features/v2-rest-query-contract.md
|
|
20
22
|
- ../../_underscore/features/calculated-sql-fields.md
|
|
23
|
+
- ../../dbchanges2/features/rerunnable-additive-inserts.md
|
|
24
|
+
- ../../api2/features/v2-api-error-codes.md
|
|
21
25
|
- ./record-modals-and-nested-tables.md
|
|
22
26
|
- ../../../clients/compass-usa/workflows/changing-a-kit-line-item.md
|
|
23
27
|
---
|
|
@@ -35,8 +39,20 @@ updated on a PUT, and the calculated price fields need `calcDepth: 2` when read
|
|
|
35
39
|
|
|
36
40
|
### Reading — `_unitPrice` / `_leasePrice`
|
|
37
41
|
|
|
38
|
-
Both are `FIELD_SQL` calculated fields on `_Model_Client_BundleItem
|
|
39
|
-
|
|
42
|
+
Both are `FIELD_SQL` calculated fields on `_Model_Client_BundleItem`, and each is a **gated
|
|
43
|
+
COALESCE**, not a plain lookup:
|
|
44
|
+
|
|
45
|
+
1. **Gate** — `_unitPrice` returns NULL unless the bundle item's `defaultPriceType.slug` is `each`;
|
|
46
|
+
`_leasePrice` unless it is `lease`. Wrong default price type → both fields are NULL, which is not
|
|
47
|
+
a missing-data bug.
|
|
48
|
+
2. **First choice** — the `BundleItemPrices` row matching `defaultPriceTypeId` (`ORDER BY id DESC
|
|
49
|
+
LIMIT 1`, **newest** wins).
|
|
50
|
+
3. **⚠ Fallback** — if that row does not exist, it **COALESCEs to the ITEM's `ItemPrices` row** for
|
|
51
|
+
the same price type.
|
|
52
|
+
|
|
53
|
+
So a price on screen is **not proof that a `BundleItemPrices` row exists**. Confirmed on
|
|
54
|
+
client-sandbox: `SELECT * FROM BundleItemPrices WHERE bundleItemId = 67` returned no rows while the
|
|
55
|
+
modal displayed 166 (the item price). Query the table before assuming you are editing an existing row.
|
|
40
56
|
|
|
41
57
|
- The table columns are sourced from these fields, not from the `bundleItemPrices` array.
|
|
42
58
|
- For **nested** bundle items (inside `GET /v2/bundles/{uuid}`) they need **`calcDepth: 2`**, sent
|
|
@@ -73,16 +89,42 @@ established it **creates a new row** rather than overwriting an unknown one. Nev
|
|
|
73
89
|
be proven. Local-copy counts: `Client_Compass` 5117 rows / 2605 duplicates / 971 groups (36 groups
|
|
74
90
|
with differing prices); `Client_CompassCanada` 333 / 75 / 63; all other tenants zero. **Prod counts
|
|
75
91
|
not yet taken.**
|
|
76
|
-
-
|
|
77
|
-
`dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql` and the
|
|
78
|
-
`Client_CompassCanada` twin
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
- **⚠ The dedupe migration BREAKS every bundle item price insert — open bug as of 2026-10-01.**
|
|
93
|
+
`dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql` (and the
|
|
94
|
+
`Client_CompassCanada` twin) keeps `MAX(id)` per `(bundleItemId, priceTypeId)`, then adds a unique
|
|
95
|
+
key on `(bundleItemId, priceTypeId, currencyId)` and makes `currencyId` **NOT NULL DEFAULT
|
|
96
|
+
`<currency>`**. The column DEFAULT does **not** cover the writers: `_Model` writes every declared
|
|
97
|
+
field, so it emits `currencyId = NULL` explicitly and an explicit NULL never falls back to a column
|
|
98
|
+
DEFAULT. `_Model_Client_BundleItemPrice` and `_Model_Client_ItemPrice` declare `currencyId` as a
|
|
99
|
+
plain FK, and nothing in PHP or the SPA ever sets it. Live on client-sandbox:
|
|
100
|
+
`Error #: 1048 / Column 'currencyId' cannot be null` on
|
|
101
|
+
`INSERT INTO BundleItemPrices SET … currencyId = NULL, price = 200`. The NOT NULL is load-bearing —
|
|
102
|
+
with `currencyId` NULL the new unique key stops nothing, because MySQL treats each NULL as distinct.
|
|
103
|
+
General rule: [a column DEFAULT never protects a `_Model`-written column](../../dbchanges2/features/rerunnable-additive-inserts.md).
|
|
104
|
+
**Fix undecided** (as of 2026-10-01): (a) default `currencyId` to the client's currency in
|
|
105
|
+
`_Model_Client_BundleItemPrice` / `_Model_Client_ItemPrice` — no SQL, no FE deploy, recommended; or
|
|
106
|
+
(b) have the SPA send `currency: { uuid }`, which **also needs** the ACL grant below.
|
|
107
|
+
The dedupe itself is otherwise safe: no foreign key references `BundleItemPrices` and
|
|
108
|
+
`SalesOrderItems` snapshots its own price column, so no order or quote is affected.
|
|
109
|
+
- **⚠ The `currencies` route has no ACL grant for the Compass roles**, so the SPA cannot read a
|
|
110
|
+
currency at all. `GET /v2/currencies` → **403 `EZ-1`** (recordId **215**, roleIds `[1,7,9,4,5,15,12,16]`);
|
|
111
|
+
a nested `itemPrices.currency.uuid` projection returns 200 with `"currency": null`. Any front-end fix
|
|
112
|
+
that needs a currency uuid therefore requires a **dbchanges2 `AclRecordPermissions` seed first** — it
|
|
113
|
+
is not a front-end-only change. See [ACL permission chain](../../_underscore/features/acl-permission-chain.md).
|
|
114
|
+
- **⚠ A failed price save can report SUCCESS.** api2 returned `status: 200` / `isSuccess: true` while
|
|
115
|
+
carrying the `EV-10` DB failure inside `messages[].identifiers.error`, so blox `apiPut` did not throw
|
|
116
|
+
and the modal showed "Bundle item updated" on a write that never landed. Check `messages[]` for
|
|
117
|
+
`type: ERROR` — see [V2 error codes](../../api2/features/v2-api-error-codes.md).
|
|
82
118
|
- **⚠ FIELDS JSON edits appear to do nothing** — the persisted React Query cache freezes them; see
|
|
83
119
|
[persisted query cache](./persisted-query-cache.md).
|
|
84
120
|
|
|
85
121
|
## Change history
|
|
122
|
+
- 2026-10-01 — Corrected `_unitPrice`/`_leasePrice`: both are price-type-gated and **COALESCE to the
|
|
123
|
+
item's `ItemPrices` row**, so a displayed price does not prove a `BundleItemPrices` row exists.
|
|
124
|
+
Recorded the open breakage from the 2026-09-24 dedupe migration (`currencyId` NOT NULL DEFAULT vs.
|
|
125
|
+
`_Model` writing an explicit NULL → 1048 on every insert), the missing `currencies` ACL grant
|
|
126
|
+
(record 215, EZ-1), and the 200/`isSuccess:true`-with-an-ERROR-message false success (TRUE-82138).
|
|
127
|
+
(apeterson)
|
|
86
128
|
- 2026-09-24 — Documented the two-call save (price edits were inserting duplicates via a nested PUT),
|
|
87
129
|
the strict-`priceType` row match with create-not-guess fallback, the `_unitPrice`/`_leasePrice`
|
|
88
130
|
columns needing `calcDepth: 2` as a plain query param, and the Compass/Compass Canada
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: [jcardinal, apeterson, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/amplify.yml
|
|
@@ -42,6 +42,21 @@ How toga25-supply deploys to all its Amplify environments from one shared `ampli
|
|
|
42
42
|
|
|
43
43
|
For every remote `_*` branch: `VITE_MODE="${b#_}"; [ -f ".env.$VITE_MODE" ]`. That tells you exactly which branches would fail, in seconds, without a deploy. Restoration of a lost spec/env-set is a verbatim copy, not a rewrite: `git checkout <known-good-ref> -- .env.<mode> …` (19 files restored from `origin/_sandbox-client-backup820`, comments intact). Do **not** overwrite files that already exist (`.env`, `.env.development`, `.env.stage` were left alone). Env-file format is `VITE_API = 'https://...'` — spaces around `=`, single-quoted — and they contain only public base URLs, no secrets.
|
|
44
44
|
|
|
45
|
+
### Prove a change actually reached a deployed environment (do this FIRST)
|
|
46
|
+
|
|
47
|
+
Before debugging "my change is not showing up", confirm whether the built bundle contains it. Read the
|
|
48
|
+
deployed artifact — never infer it from the merge.
|
|
49
|
+
|
|
50
|
+
1. `curl https://<tenant>.client.sandbox.togasupply.com/` and read the `/assets/index-<hash>.js` path
|
|
51
|
+
out of the HTML.
|
|
52
|
+
2. `curl` that asset and `grep` for a string unique to your change (a route segment, a field name —
|
|
53
|
+
e.g. `bundle-item-prices`, `_unitPrice`, `_leasePrice`).
|
|
54
|
+
3. Read the asset's `last-modified` response header for the actual build time.
|
|
55
|
+
|
|
56
|
+
A hit means the front end is deployed and current, and the fault is **elsewhere** (API, ACL, DB) —
|
|
57
|
+
this settled TRUE-82138 in one step on 2026-09-29 (bundle carried all three strings,
|
|
58
|
+
`last-modified: Tue, 29 Sep 2026 21:58:19 GMT`).
|
|
59
|
+
|
|
45
60
|
### Reference — how the build resolves mode (load-bearing)
|
|
46
61
|
|
|
47
62
|
- Branch name convention is **`_<mode>`** — a single leading underscore followed by the mode.
|
|
@@ -86,6 +101,15 @@ Worse, **`.env.development` is git-TRACKED**, so developers keep flipping it to
|
|
|
86
101
|
- **`amplify.yml` existed in exactly ONE commit (`5a276be`) and was absent from the root of EVERY remote branch — including `_production`.** So Amplify had been building from a **console-stored** build spec, which means the fail-loud `_<mode>` guardrail was **not actually running**. It was recreated on `_sandbox-client`. The recreated spec runs **`npx tsc -b` before `vite build`**, which the previous version did not: **a type error now fails the deploy.**
|
|
87
102
|
- **TWO competing `.env` conventions coexist across branches.** The `_<mode>` convention needs `sandbox-client`, `sandbox-dev`, `client-*`, `qa-*`, `qc-*`, `production`, `demo`, `stage`. But `_sandbox-client`, `_production`, `_beta` and `_main` carry an **OLD** set (`.env.alpha` / `.beta` / `.gamma` / `.sprint` / `.test`); only `_stage`, `_sandbox-dev` and the snapshot `_sandbox-client-backup820` carry the new 21-mode set. Dry-running the derivation across all **27** remote `_*` branches, only **`_beta` and `_stage`** would have built. A "reset from prod" is how a branch loses the new set.
|
|
88
103
|
- **⚠ Open data issue, needs a human:** on `_sandbox-client`, `.env.stage` points at `https://api.dev.sandbox.togahub.com/v2` (**dev-sandbox**) while the backup has `https://api.stage.togahub.com/v2`. As-is, `_stage` would deploy pointing at dev-sandbox. Also 4 orphan old-convention files remain with no matching `_<mode>` branch (`.env.alpha`/`.gamma`/`.sprint`/`.test`; `.env.beta` still has one).
|
|
104
|
+
- **⚠ The `amplify.yml` deletion RECURS — and a feature branch can carry it into a release branch.**
|
|
105
|
+
Verified 2026-09-29: commit **`37f7aeb` "Remove yml file"** deleted `amplify.yml` from
|
|
106
|
+
**`_sandbox-client`**, riding in with the TRUE-82138 feature-branch merge. So `_sandbox-client` is
|
|
107
|
+
again building from the **console spec**, losing the `npx tsc -b` gate, the Cypress test phase, and
|
|
108
|
+
the fail-loud `_<mode>` guardrail — the exact state the 2026-08-31 entry above fixed. Current
|
|
109
|
+
presence (2026-10-01): `amplify.yml` exists **only** on `_sandbox-dev`; absent on `_production`,
|
|
110
|
+
`_sandbox-client`, `_stage`, `_main`. **Check `git log --diff-filter=D -- amplify.yml` on any branch
|
|
111
|
+
you merge into a release branch**, and re-check the file exists after every merge, not just after a
|
|
112
|
+
reset-from-prod.
|
|
89
113
|
- **Deleting a remote branch silently breaks its Amplify deploy trigger.** An Amplify branch is **its own resource with its own webhook association** — **not** inferred from the repo. Deleting the git branch either deletes the Amplify branch resource (when automatic branch deletion is enabled) or leaves it with a stale association. Either way **re-creating the git branch does NOT reconnect it**: pushes land in GitHub and Amplify ignores them.
|
|
90
114
|
- **A missing `amplify.yml` is NOT the cause of a missing trigger** — a build would still start and fall back to the console spec. Don't conflate the two.
|
|
91
115
|
- **Diagnose:** GitHub → *Settings → Webhooks* → the Amplify webhook → **Recent Deliveries**. A delivery with `200` and no Amplify job = the branch is not connected; **no delivery at all** = the webhook is gone. Or `aws amplify list-apps` then `aws amplify list-branches --app-id <id>`.
|
|
@@ -97,3 +121,9 @@ Worse, **`.env.development` is git-TRACKED**, so developers keep flipping it to
|
|
|
97
121
|
- **⚠ The missing-blox-declaration defect RECURS per branch — it is not fixed once.** Verified 2026-08-31: `_sandbox-client` **also** had no `@agilant/toga-blox` dependency entry and no lock entry despite ~200 imports, exactly as `_production` had (fixed there in `92644c8`). The symlink hides it, so **re-run the audit on every branch you deploy**. Fixed by installing the **exact** pin `1.1.1-sandbox-client.140` (never a caret — some published versions ship an empty `dist`). That install surfaced one more masked peer: **`react-day-picker`** (blox peer `^9.5.1`) was neither declared nor present in the app's own `node_modules`; added at `^9.14.0`. All 19 blox peers are now declared. Installing from the registry also replaced the symlink with a real directory, which independently removed the duplicate-`@tanstack/react-query` root cause (see [local-link-into-a-consumer-app](../../toga-blox/workflows/local-link-into-a-consumer-app.md)). Verified after install: `tsc -b` clean, 44/44 tests, `vite build --mode sandbox-client` clean (1773 modules), exactly one react-query copy.
|
|
98
122
|
- **⚠ Latent and NOT fixed — `framer-motion` peer mismatch.** The app declares `^12.35.0`; blox's peer range is `^11.0.8`; `legacy-peer-deps=true` silenced the warning, so blox installed a **nested framer-motion v11**. framer-motion provides React context (`AnimatePresence`, `MotionConfig`) — i.e. the **same duplicate-instance bug class**, just not throwing today. Fix direction: widen blox's peer to `^11 || ^12`, **or** add `framer-motion` to the app's dedupe/alias list (which forces blox onto v12 — needs testing).
|
|
99
123
|
- **The audit that catches the blox defects — reusable for any blox consumer.** Enumerate the shared lib's `peerDependencies` from `node_modules/<pkg>/package.json`, then check each one for a **real directory** (not a symlink passthrough) in the *app's* `node_modules`. Do this whenever a repo has ever been developed against a linked local checkout.
|
|
124
|
+
|
|
125
|
+
## Change history
|
|
126
|
+
- 2026-10-01 — Added the "prove the change reached the environment" bundle-fetch check (curl the
|
|
127
|
+
`/assets/index-<hash>.js`, grep for a unique string, read `last-modified`), and recorded the
|
|
128
|
+
`amplify.yml` deletion recurrence on `_sandbox-client` (commit `37f7aeb`, 2026-09-29) carried in by a
|
|
129
|
+
feature-branch merge — the file now exists only on `_sandbox-dev`. (apeterson)
|
package/package.json
CHANGED