Technical changelog
Every commit, with the traps found along the way.
The engineering view of What's new. Newest first. Hashes are
from main in VIGOR-Digital-Solution/autoxspare-2.
29 October 2026 — One parcel weight
/store/shipping-quoteshad its own copy of the weight rules (1000 g assumed, 30 kg cap) while the EasyParcel provider used 2000 g and 70 kg, so the courier chooser andshipping_methods[].amountdisagreed for every cart under 2 kg and every part with no weight.parcelGrams()is now exported frommodules/easyparcel/service.tsand both use it; covered inreliability.unit.spec.ts.- Staff guides: the admin Refund runs
refundPaymentWorkflow, which refunds at Curlec. The guide said to refund in Curlec and then "record" it in the admin, which is two real refunds.order-canceled.tsrefunds captured payments itself, contrary to the old Orders page.
28 October 2026 — No storefront previews
apps/storefront/vercel.jsonsetsignoreCommandto skip Preview builds (exit 0 skips, so the test is= "preview", not!=). Vercel's Preview environment has no variables, so every PR build failed in under a second onNEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY. Do not fix that by copying the Production values: a preview would then run against the live backend and live Curlec. Restore previews once a staging backend exists.- Vercel does deploy
mainto production from git (productionBranch=main), just like Railway. It finishes about five minutes before Railway, so a change where the storefront needs a new backend route must ship the backend first.
6 September 2026 — Audit repairs
- Curlec returns resolve the original cart through
POST /store/curlec/return, which validates the API-key HMAC and gateway-order/session amount, currency and provider association. Core cart completion never reinitializes the session. Provider authorization queries payments on the original gateway order and accepts only matching authorized/captured MYR payments. Gateway reads preservedata.idas the gateway order id. - EasyParcel OAuth requires an admin session on both routes. Random state has a ten-minute cache TTL, is bound to the user and browser session, and is consumed under a shared lock. Callback text is HTML-escaped.
- Fulfillment ignores caller courier overrides. Quote caching includes parcel value. Token refresh re-reads inside a transaction/row lock in the existing token store, coordinating separate clients and workers without a new table.
- Aftercare writes run in workflow steps. Product votes use a hashed receipt ledger and count in one write, protected by product/order locks. The order mirror is repaired on retry. Return requests persist before notification and use stable notification keys; the mail provider receives the same idempotency key. Failed notification retries retain their original row id to avoid Medusa 2.19 assigning a fresh id to an existing idempotency key; interrupted pending rows become retryable under the per-request lock.
- Catalogue pages already render dynamically (request cookies and search params). Removed unused build-time catalogue enumeration: builds no longer require a live Medusa database just to discover dynamic product/category/collection URLs. The docs use the existing system font stack instead of downloading Google Fonts.
- Storefront type errors are no longer ignored. Explicit immediate cache expiry works in actions and route handlers. Storefront React type resolution stays inside its workspace; backend React 18 aliases remain intact. Root tests run the backend suite; CI runs typecheck, lint, tests and backend/storefront/docs builds.
- Existing synchronous state updates in effects remain lint warnings, not suppressed diagnostics; hook correctness and render-time ref checks remain errors. This remains UI cleanup, not a claim that the codebase is lint-clean.
Validation and release work are tracked in docs/audit-todo-2026-09-06.md.
No stock counts or weights were guessed, and no paid test shipments were booked.
3 September 2026 — Courier choice at checkout
The courier must be the option, not data on the method
apps/backend/src/scripts/setup-courier-options.ts
The first attempt carried the chosen courier on the shipping method's
data.service_id. It cannot work, and it fails silently.
addShippingMethodToCartWorkflow resolves the amount through
listShippingOptionsForCartWithPricingWorkflow using only option_ids
(add-shipping-method-to-cart.js:66-78). The data posted with the method
reaches validateFulfillmentData afterwards and is stored on the method at
line 116, while amount was already taken from shippingOption.calculated_price
at line 113. So the courier was recorded accurately and priced not at all.
calculatePrice(provider_id, optionData, data, context) does receive the
option's own data column, so the courier lives there instead: one calculated
shipping option per courier, data: { service_id }, eight of them.
Nothing about this is visible in a response. The method stored the right
service_id, the option was right, the quote endpoint was right. Only
comparing the quoted price against cart.shipping_methods[].amount exposed
it — picking Pos Laju at 12.09 charged 8.60.
Availability cannot come from the option list
Options are configured per region; courier coverage is per postcode. Measured: Penang and KL return 18 quotations, Kota Kinabalu 12, Kuching 13.
An option whose courier does not serve the destination cannot simply throw —
fulfillment-module-service.js:759 prices every option in one Promise.all, so
one rejection loses them all. It returns the cheapest available rate instead and
the storefront filters it out, using /store/shipping-quotes, which returns
only genuinely available couriers. That endpoint now resolves each quotation to
its option_id, because option.data is not exposed on
/store/shipping-options — verified, it comes back null — so the storefront
has no other way to tell which option is which courier.
Nine options, one API call
Per-courier options mean Medusa prices nine options per cart, which was nine
identical EasyParcel quotations. quoteFor caches per from:to:grams:parcelValue for 60s
and caches the promise, not its result, so calls arriving together collapse
into one request rather than racing to fill the same slot. A rejection evicts
the key so an outage is not pinned for the whole TTL.
validateFulfillmentData had to be taught the courier
Once the courier became the option, the storefront stopped sending a
service_id, so returning the method data unchanged would have had
createFulfillment submit an empty service and let EasyParcel choose — priced
as one courier, shipped as another. It now copies service_id off optionData.
Service ids are stable across routes
Confirmed against Bayan Lepas, Kuala Lumpur, Johor Bahru, Kota Kinabalu,
Kuching and Jitra before being pinned in the script. Pickup variants are
preferred over drop-off at equal price; EP-CS0MO (Lazada) and EP-CS09Q /
EP-CS0MP (Celsius) are drop-off only and excluded.
PaymentActions has no refund member
apps/backend/src/api/hooks/curlec/route.ts
/hooks/payment/:provider maps webhooks onto PaymentActions, whose members
are authorized, captured, failed, pending, requires_more, canceled,
not_supported and pending_authorization. A refund cannot be expressed, and
PaymentEvents only emits payment.captured / payment.refunded, so nothing
fires on failure either.
Both needed a container, which a provider service does not get, so they live in
an API route with bodyParser: { preserveRawBody: true } declared in
middlewares.ts - the signature is over the exact bytes sent.
A refund is reported, never replayed. Recording it against the payment
would run refundPaymentWorkflow, which asks the provider to refund - a
second, real refund. The order is annotated and the shop emailed instead.
react was a devDependency
medusa build writes .medusa/server/package.json from declared dependencies
only, and npm install --omit=dev skips devDependencies. React was present in
production purely transitively - and --legacy-peer-deps, added the same day,
stops npm installing peers. One dependency bump from every template failing with
Objects are not valid as a React child, inside a subscriber, silently.
scripts/check-emails.ts renders all eight in the production runtime without
sending, and reports the React version so the two-copy case is visible.
/wallet-balance 404s on 2026-06
The token-refresh job originally probed walletBalance(). That endpoint is not
available on this API version, so the job would have emailed "live rates are
OFF" every morning while rates were fine. It probes with a real quotation now,
which also exercises the exact call checkout depends on. Wallet balance is best
effort and never raises an alert.
Also corrected: the refresh token runs to 2027-09-03, not 30 days.
A 401 is not a 500
lib/util/session-expired.ts
The starter routes every failed customer call through medusaError, which
throws - so an expired token rendered "This page couldn't load" on
/account/orders and "An error occurred, please try again" on the profile form.
Retrying could not work: the stale token stayed in the cookie. A 401 now clears
it and redirects to the account page.
JWT_SECRET and COOKIE_SECRET are both set on Railway, so this was a genuine
expiry rather than redeploys invalidating sessions - worth checking first,
because the symptom is identical.
One Skeleton, 76 placeholders
apps/storefront/src/modules/common/components/skeleton/index.tsx
shadcn's Skeleton is one div and two utility classes, so it was rebuilt on this
shop's bg-grey-10 rather than installed - there is no Radix or cmdk behind it
to justify adding components.json. clx from the existing UI barrel is
already clsx + tailwind-merge, which is what shadcn's cn is.
The first sweep was a regex over <div className="...animate-pulse...">, and
it converted wrapper divs that merely carried the animation for their
children into <Skeleton> while leaving their </div> behind - broken tags
in six files. The starter nests placeholders under an animated parent, so
"has animate-pulse" does not mean "is a placeholder".
The rule that works: convert only leaf elements - <div className="..." />
and <div className="..."></div> - and separately strip animate-pulse from
wrappers, since each placeholder now animates itself. Verify with a grep for
<Skeleton lines lacking /> before building.
Also reverted cleanly first (git checkout -- modules/skeletons) rather than
patching the damage, which is the cheaper move when a codemod misfires.
Combobox and courier logos
The courier picker reuses SearchableSelect, the Headless UI combobox the
fitment selector already uses - listbox ARIA, type-ahead and keyboard nav
already solved - rather than adding a second one. Its Option type grew an
optional icon URL, rendered as a plain <img> with empty alt (the courier's
name sits beside it).
Logos come from courier_logo on the quotation, which
/store/shipping-quotes was already passing through; all nine verified 200
from s3-ap-southeast-1.amazonaws.com. Plain <img> rather than next/image
so no remote pattern has to be configured for a third-party host.
The deploy failure was npm install, not the build
railway.json
Six consecutive railway up failures, initially misdiagnosed as build memory
twice. Railway's buildLogs over GraphQL showed
Frontend build completed successfully (24.93s) immediately followed by
npm error code ERESOLVE — the failure was npm install --omit=dev inside
.medusa/server, where the react-email packages conflict against
@medusajs/framework@2.19.0. --legacy-peer-deps fixes it.
The CLI's tail output is not reliable here — it showed an older successful
deployment's log. Fetch buildLogs by deployment id over GraphQL instead of
trusting what railway up prints. A later TLS handshake timeout reaching
registry-1.docker.io was genuinely transient and passed on retry.
tsconfig.tsbuildinfo was tracked
A git stash refused to pop because the build cache changed underneath it,
stranding the work in the stash. Now in .gitignore and removed from the index.
Recover with git checkout -- <artifact> then git stash pop.
1 September 2026 — Go live
Category links dropped the vehicle filter
apps/storefront/src/modules/garage/components/garage-link/index.tsx
The garage lives in localStorage, so CategoryFilter (a server component)
built plain /categories/<handle> hrefs with no vehicle= params. The listing
code was correct throughout - adding the params by hand returned 11 correctly
filtered products - but every sidebar and sub-category link silently discarded
the car, while the header chip kept asserting it was applied.
GarageLink appends garageQuery(garage) client-side. Until the garage is read
ready is false and the bare href is used, which errs toward showing more parts
rather than fewer.
Anything navigating into a listing must use GarageLink, not
LocalizedClientLink, or it will drop the vehicle again. A filter the page
claims to apply and does not is worse than no filter.
Known remaining inconsistency: sidebar counts are global (Suspension 1,539)
and do not reflect the active vehicle, so they disagree with the result count.
Making them vehicle-aware costs one extra count query per group.
Unknown handles returned 500 instead of 404
getImagesForVariant(pricedProduct, ...) ran before the if (!pricedProduct) notFound() guard and dereferenced undefined. Confirmed in the Vercel runtime
logs as TypeError: Cannot read properties of undefined (reading 'images').
Moving the guard above the call fixes it.
Separately, only 83 Shopify collections are mapped in legacy-redirects.ts;
unmapped ones fell through to /my/collections/<handle>, a route the storefront
does not serve. resolveLegacyPath now falls back to a search, and maps
Shopify's nested /collections/x/products/y shape too.
5064623 — the admin login, this documentation site, and the backend URL fix:
- Admin login rebranded —
apps/backend/src/admin/widgets/login-branding.tsx - This documentation site —
apps/docs, Fumadocs on Next 16 MEDUSA_BACKEND_URLrepointed tohttps://api.autoxspare.comso the admin calls the origin it is served from
Medusa 2.19 has no supported way to replace the login route: it is compiled into
@medusajs/dashboard and the copy comes from that package's i18n bundle. What it
does expose is a login.before zone rendering inside the login column, so the
widget supplies AUTOXSPARE branding and hides Medusa's. It finds Medusa's markup
by walking up from its own ref and hiding every sibling of the form wrapper,
rather than matching Tailwind class names that change between releases.
The screen is pinned to light because the wordmark is black on transparent and
vanishes on the dark theme. Colours are scoped to the login shell rather than
written to medusa_admin_theme, so the rest of the admin keeps the user's choice.
Committed the same day:
| Hash | Change |
|---|---|
a3d2bcf | Script to diagnose admin logins |
59f6fec | Stop showing React internals to someone trying to pay |
79d530a | MYR only, and show the gateway mode in the admin |
cb6816b | Switch gateway and courier between test and live with one variable |
461dae4 | Link the Jitra warehouse to the sales channel, round the fitment fields |
763a4d2 | One Malaysia-wide shipping option, not two |
8019463 | Give Malaysia somewhere to ship from |
6a22c5e | Stop the product page promising two contradictory things; fix terms layout |
15d528c | Make Curlec the only way to pay; remove the Medusa favicon |
2fec830 | Style the store sidebar, and give search a type-ahead |
enforce-myr.ts matters more than it looks: the seed left
supported_currencies as EUR + USD, so the admin offered currencies that would
save on a price list and then never apply to anything.
31 August 2026 — Redesign and integrations
| Hash | Change |
|---|---|
1bead07 | Never show an empty shelf when the garage filter misses |
7f7e6e6 | Google, Apple and Facebook sign-in |
17edcbd | Make the homepage ask the only question that matters |
c5c12ab | Wire Curlec into checkout, drop the European payment methods |
4f7276d | EasyParcel fulfillment provider |
2083d1e | Curlec payment provider |
60d0124 | Recover the parcel weights the import dropped |
5afa249 | Spec the Curlec and EasyParcel integrations |
6edc967 | Handle bracketed part numbers, give a dead search somewhere to go |
8053c2c | Make the search box actually search |
45b4124 | Let the fitment selector follow the URL |
e145439 | Script to repair the vehicle fitment data |
aa2bdd3 | Make the fitment selector searchable |
ae8b027 | Fix what the accessibility and mobile audit turned up |
916e142 | Take the Medusa and Next.js branding out of the shop |
8f47724 | Rebuild the product page against the Shopify theme |
9d55a7d | Rebuild the homepage against the Shopify theme |
updateProductTags(id, { metadata }) does not work — the DTO only carries
value. Use updateProductTagsWorkflow from @medusajs/core-flows.
Search was routed through a server route (/api/search/suggest) rather than the
browser calling Medusa directly, because STORE_CORS did not list the live
storefront domain and a browser fetch died at preflight with a bare
"Failed to fetch" — which renders as "no results" rather than as an error.
18 August 2026 — Storefront depth
| Hash | Change |
|---|---|
acc86cd | Delete a customer's login too; stop trusting tokens for deleted customers |
3414095 70230d9 | Cleanup script for migration test debris, armed by env var |
8f48880 f4a2188 | Carry Shopify's stock status across, label pre-orders |
8a162b1 | Let customers browse past the first 100 products |
dd2b0ee | Bring the store page up to the Shopify collection page |
bb4a1b4 | Serve resized images instead of full-resolution sources |
15d19ac | Shop megamenu |
547f34a 4a29e75 | Parent categories show their products; Bestseller tabs |
3b89a22 | Preserve search rankings across the Shopify cutover |
ae17b8e 2bc83b4 | Card hover tray, product rails, compare, hero carousel |
190066b | Move the storefront to Singapore |
f03bdd9 c805bd9 | Tie the wishlist to the customer account, merging guest saves |
b134e5f | Stop product titles colliding with their price on phones |
2b8283e 78069b4 | Restore store filters; native sort control on phones |
2e18c18 c77d7da | Mobile tab bar, wishlist fetched server-side |
4364477 | Contact, terms and privacy pages |
190066b is worth knowing about: the storefront was defaulting to Vercel's US
East region, costing roughly 5× the TTFB for Malaysian visitors. It is pinned to
sin1, and that is dashboard configuration, not in the repo.
17 August 2026 — Images and theme
| Hash | Change |
|---|---|
05451fb | Stop the cart wordmark clipping off the mobile header |
6555547 | Brand the mobile drawer, drop the last Shopify CDN reference |
582034d | Fix mobile: restore search, compact the fitment bar |
047acaf | Replace the Medusa default footer |
054836e | Match the Shopify theme: brand tokens, header, hero, fitment bar |
552bbe3 | Rebuild the storefront homepage around fitment search |
c6e54ea 862eb83 9786c39 95f3707 | Thumbnail repairs |
fd4bf50 | Drop cache: force-cache so next.revalidate governs freshness |
72b2e27 | Allow the R2 hostname in next/image remotePatterns |
85824ce | Move product images to Cloudflare R2, repoint 8,708 references |
product.thumbnail is stored separately from product.images. Updating images
alone leaves the storefront on the old host, and it must be cleared with '' —
Medusa ignores null.
15–16 August 2026 — Foundation
| Hash | Change |
|---|---|
e3d021e | Audit infra, remove redundancy, document traps in AGENTS.md |
6aa0cbf | Fix storefront caching: stale catalogue, uncacheable 7.4MB fetch |
bcf83c9 | Park the Railway image map; Railway buckets cannot serve public URLs |
8175c52 | docker-compose for local dev, project README |
98dfb43 | Convert Shopify HTML descriptions to plain text on import |
0dc6c86 | Native-Medusa Shopify importer: 4,445 products, 3,299 fitment links |
673756b | Shopify import parser and dry-run report |
a0937fd 3412bb8 | Deploy config; Vercel root directory is apps/storefront |
dbde6f2 13cc4d3 | Redis module registration and production Postgres SSL |
0006225 73c7227 19ff201 7cf9c14 | Shopify audit: a launch, not a migration |
10b83ff | Fix blank admin (React hoisting), upgrade storefront to Next 16 |
6571c43 | Scaffold the Medusa v2.19 monorepo |
925d902 | Initial commit |
Deployment traps
Each of these cost a failed build or a wrong diagnosis:
| Trap | Detail |
|---|---|
medusa develop skips typecheck | Backend type errors only fail the Railway build. Run npm run build in apps/backend first. |
| Admin renders blank | npm hoists React 19 over the admin's React 18; fixed with a Vite alias in medusa-config.ts. |
| Shipping options come back empty | The sales channel must be linked to the stock location with linkSalesChannelsToStockLocationWorkflow. Nothing errors; the options are simply absent. |
| Vercel needs a team scope | Bare vercel --prod returns "Not authorized"; pass --scope vigor-digital-solution. |
| Two Vercel projects, one repo root | The root .vercel link points at the storefront, so deploy the docs with an explicit VERCEL_PROJECT_ID. Neither project has a git integration — git push deploys nothing. |
vercel env add defaults to sensitive | A sensitive NEXT_PUBLIC_* cannot be read at build time; the build silently ships the fallback. |
| Railway CLI token | ~/.railway/config.json has both token (null) and accessToken. Reading token yields Not Authorized, which reads like a permissions problem. The token also expires — railway whoami refreshes it. |
Custom domain targetPort | Can be created null, in which case Railway's edge returns Application not found. |
| Certificate stuck "validating" | Check port 80 first. If http:// returns 200/301 while https:// fails, routing is fine and only the cert is missing. If port 80 also fails, the edge does not know the host and a cert can never issue. |
Keeping these docs current
These docs are part of the work, not a write-up afterwards. When a change lands:
- Add a plain-language entry to What's new — what someone running the shop would notice, not what the code does.
- Add the engineering detail here, with the commit hash.
- Update the API reference if a route, payload or environment variable changed.
- Update the user guide if a screen or workflow moved.
A change that alters behaviour but ships no docs is unfinished. If a fix later turns out to be wrong, correct the original entry rather than appending a contradiction — a changelog that argues with itself is worse than none.