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.
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
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-09-28
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 24h persisted query cache, and how to opt a query out of it](features/catalog-cache-freshness.md) | The 24h persisted React Query cache that serves stale catalog/copy, how to opt a purchase-gating query out of it, and the four stacked causes behind "stale data |
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 24h persisted query cache, and how to opt a query out of it
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-09-22
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 24h persisted React Query cache that serves stale catalog/copy, how to opt a purchase-gating query out of it, and the four stacked causes behind "stale data / cache won't clear / stuck login" complaints; open for any freshness or forced-logout bug.
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-09-22 (TRUE-81991, `e546db3d`):** causes 1, 2 and 3 are **fixed**; cause 4 (the core-parameters force-logout) is still open. Read the cause sections for what the fix was — the history below is kept because it explains *why* each knob is set the way it is.
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
- TOGa Commerce ran a **single `QueryClient` with a 24-hour default `staleTime`**, persisted to **`localStorage["commerce"]`** via `PersistQueryClientProvider`. That combination served **catalogue state up to 24 hours out of date, across full page reloads** — in **both directions**: after an item is switched off, a user who had already viewed it keeps seeing the live product page with a working Add to Cart; after it is switched back on, it stays hidden.
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). Nothing has been fixed yet.
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: cached and persisted
50
+ ### The default: fresh in memory, still persisted to disk
43
51
 
44
- `src/App.tsx:26-31` creates the client with `staleTime: 1000 * 60 * 60 * 24` and a `createSyncStoragePersister` on `localStorage` key `"commerce"` (`src/App.tsx:34-37`). Everything inherits that unless it overrides it — including the `["clientFields", …]` query that carries the per-tenant `FIELDS` content, which is why copy/config deploys also look stale for returning users (see the [architecture gotchas](../architecture.md#gotchas)).
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
- For comparison, **TOGa 2.5 Supply's default is `staleTime: 0`** *because* its cache is persisted. Same library, same persister, opposite default. See [supply's persisted query cache](../../toga25-supply/features/persisted-query-cache.md).
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-09-24
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`: they resolve the row
39
- matching `defaultPriceTypeId` with `ORDER BY id DESC LIMIT 1` (**newest** row wins).
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
- - **Dedupe migrations exist but have only been run locally** —
77
- `dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql` and the
78
- `Client_CompassCanada` twin keep `MAX(id)` per `(bundleItemId, priceTypeId)` — the row the
79
- calculated fields already read, so nothing changes on screen. Safe to run: no foreign key anywhere
80
- references `BundleItemPrices`, and `SalesOrderItems` snapshots its own price column, so no order or
81
- quote is affected. `currencyId`/`planId` are NULL on every row.
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-09-16
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.929",
3
+ "version": "1.0.930",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",