Repository navigation
fix(cache): invalidate storefront API list caches when the tables they embed change - #140
Merged
Merged
Conversation
…events Query builder updates and deletes bump no ApiModelCache version, so the cached lists embedding those rows kept serving them until the TTL. Each such write now invalidates its table explicitly, with the company when known; the writes themselves stay quiet. - CatalogProduct (catalog_category_products) uses HasApiModelBehavior like the sibling CatalogSubject and NetworkStore pivots, so pivot creates bump it; CatalogCategory::setProducts removals and override edits and CatalogObserver::deleted invalidate it by hand. - CatalogController::syncSubjects and FoodTruck::setCatalogs invalidate catalog_subjects after their builder deletes. - NetworkController membership writes (add/remove/approve/reject/suspend/ reinstate, deleteCategory) invalidate network_stores alongside stores; deleteCategory also invalidates categories after its builder delete. - Product::setAddonCategories / setProductVariants and AddonCategory::setAddons invalidate product_addon_categories, product_variants, product_variant_options and product_addons. - v1 CheckoutController::discardFailedCapture invalidates transaction_items. - storefront:purge-carts advances the table wide carts version. The lists only read these versions once they declare the tables they embed (fleetbase/core-api#301), which the next commit does.
ApiModelCache caches list results with every relation eager loaded inside the query, but versioned them by the model's own table only, so a write to an embedded table (a truck's catalog assignments, a product's variants, a store's network membership) left the list stale. fleetbase/core-api#301 adds $apiCacheDependencies; until it is released the property is unused. - Catalog: categories, categories.products, hours, assignments, and the assignment subjects' tables (morphTo: stores, food_trucks). - Store: reviews (rating average), networks (membership filter/loading). logo and backdrop are left out: a new one changes the store row, and depending on the busy files table would only cost cache hits. - FoodTruck: store, store.networks, network, vehicle, vehicle.driver, serviceArea, serviceArea.zones, zone, catalogs. - Product: addonCategories(.category(.addons)), variants(.options), files (images added to a product do not touch its row), store, store.networks, catalogCategories (catalogs_count). - AddonCategory: addons ($with). A test walks every declared relation path, so a renamed relation fails here instead of being logged and skipped in production.
… every upload Product declared files as a cache dependency, but every upload in a company writes the files table (proof of delivery photos, signatures, avatars), so product lists, the busiest public storefront endpoint, would rarely be served from cache. A FileObserver now invalidates product lists only when a file whose subject is (or was) a product is saved or deleted.
roncodes
added a commit
that referenced
this pull request
Oct 13, 2026
Conflict in NetworkController::addStores: #139 scoped the `remove` deletes to the network the same way; kept its version and comment. deleteCategory keeps the scoped network uuid plus #140's cache invalidation. Tests from #139/#140 that call network actions now give the network a company and set the session company; ApiCacheInvalidationTest calls the public reinstateStores action instead of setMembershipStatus by reflection (it now takes the resolved Network).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Storefront's cached API lists embed related tables (eager loads,
withAvg, thecatalogs_countsubquery,whereHasmembership filters), butApiModelCacheonly versioned a list by its own table. A write to an embedded table left the list stale until the TTL ran out. The case seen live was the food truck list returning its oldcatalogsafter they were reassigned. Two things caused it, and this PR fixes both:$apiCacheDependencies. The property is read byApiModelCachestarting with feat(api-cache): writes invalidate every cache that embeds the written table core-api#301 (feat(api-cache): writes invalidate every cache that embeds the written table core-api#301). This PR depends on #301 to take effect. Until #301 ships, the property is unused and harmless.DB::tabledelete. The writes stay quiet; an invalidation call is added after each, with the company when it is known.Related Issue
Related to fleetbase/core-api#301 (dependency support); there is no storefront issue.
Type of Change
Implementation Notes
Declarations (
$apiCacheDependencies)Catalog(catalogs)categories,categories.products,hours,assignments,stores,food_trucksonQueryRecordloadscategories.products,hours,assignments.subject.subjectis a morphTo, so its target tables are named directly.Store(stores)reviews,networkswithAvg('reviews','rating'), andnetworks(loaded withnetwork+with_category; also thenetworkfilter'swhereHas).FoodTruck(food_trucks)store,store.networks,network,vehicle,vehicle.driver,serviceArea,serviceArea.zones,zone,catalogsServiceArea::$with = ['zones']).store.networksdrives the network membership filter and the v1 network truck list.Product(products)addonCategories,addonCategories.category,addonCategories.category.addons,variants,variants.options,store,store.networks,catalogCategoriesAddonCategoryloadsaddons.store.networksis the network storefrontwhereHas.catalogCategoriescovers the internalcatalogs_countsubquery (categories + catalog_category_products). Product images (files, feedingimages/primary_image_url) are handled byFileObserverinstead (below).AddonCategory(categories)addons$with = ['addons'].These were resolved against the running dev container with #301's
ApiModelCache::dependencyTables(), in strict mode (an unresolvable entry throws there; in production it's logged and skipped). Product has since droppedfiles:Left out on purpose:
logo/backdrop. A new logo or backdrop changeslogo_uuid/backdrop_uuidon the store row, which already bumpsstores. File rows aren't edited, so depending on the busyfilestable would only cost cache hits.store.logo/store.backdrop. Covered bystore, for the same reason.files. Every upload in a company writesfiles(proof-of-delivery photos, signatures, avatars, imports). Declaring it would invalidate that company's product lists, the busiest public storefront endpoint, on every upload. Product images are handled byFileObserverinstead.Writes that raise no model events, now invalidating
CatalogProduct(catalog_category_products) now usesHasApiModelBehavior, matching its sibling pivotsCatalogSubjectandNetworkStore, so pivot creates bump it. Before this, product override edits bumped no table at all.CatalogCategory::setProducts: builder delete (removals) and builder update (override edits) invalidate catalog_category_products once per call, only when something was written.CatalogObserver::deleted: builder delete of category products.CatalogController::syncSubjects: builder delete of catalog_subjects, invalidated only when rows were removed.FoodTruck::setCatalogs: builder delete of catalog_subjects. The existing food_trucks invalidation is kept; catalog lists embed the assignments too.NetworkController::forgetCachedResponses()now invalidatesnetwork_storesalongsidestores. That covers addStores, rejectStores, approve/suspend/reinstate (setMembershipStatus) and removeStores.deleteCategorypreviously had no invalidation; it now callsforgetCachedResponses()and also invalidatescategoriesafter its builder delete of the category. The company issession('company')(the network operator), the same one the evented NetworkStore path uses. The network list itself embeds nothing from network_stores, sonetworksneeds no invalidation.Product::setAddonCategories/setProductVariants: builder updates of product_addon_categories, product_variants, product_variant_options.AddonCategory::setAddons: builder update of product_addons.v1/CheckoutController::discardFailedCapture: builder delete of coreTransactionItemrows. That model only hasHasApiModelBehavior, so this usesApiModelCache::invalidateModelCache(new TransactionItem(), company).FileObserver(new, registered on core'sFile): when a file whose subject is (or was) a product is saved or deleted, it invalidates product lists for the file's company. Attaching an image goes throughFile::setKey($product), and deleting one is a plain file delete; neither touches the product row. Other uploads leave product lists cached.storefront:purge-carts:DB::table('carts')delete across companies, so it advances the table-widecartsversion (Cart::invalidateApiCacheManually()). This site was not in the original audit; it was found while scanning for builder writes.CatalogObserver::savedrunssetCategorieseven on a clean save. Category and pivot creates are now evented and the builder writes invalidate by hand, so a catalog whose own row didn't change is still invalidated when its categories or overrides change.Not changed here:
NetworkController::addStoresremoves a store from every network because its delete has nonetwork_uuidfilter. That bug is being fixed separately; this PR only adds invalidation around it.Known remaining gaps
Catalognamesstores/food_trucksdirectly. Any new subject type has to be added to that list.Categorylists (v1CategoryController) aren't covered by declarations here, since that model lives in core-api;with_productsis loaded outside the cached query, so it is fresh anyway.Release review
A pre-release review changed one thing: Product no longer declares
files(above). That dependency would have invalidated the company's product lists on every upload in the company. AFileObserverscoped to product images replaces it. Tests cover a product image saved, deleted, and moved off a product (each invalidates), an unrelated upload (keeps the cache), and the provider registering the observer.Validation
PHP tests ran in the worktree with a cloned
server_vendor, with Pest's JUnit log used for results (Pest's console output is swallowed locally). No frontend changes, so no ember build or tests.composer test:unitequivalent (scripts/pest-runner.php server/tests)php-cs-fixer fix --dry-runon all touched filesApiModelCacheFull suite after the release review, run on this branch and on its merge base with the same vendor, compared test by test: base: 663 tests, 641 passed, 22 errors this branch: 677 tests, 655 passed, 22 errors failing on this branch but not on base: 0; the 14 extra tests are new and pass The 22 errors are identical on base: Class "Fleetbase\Support\SocketCluster\Socket{Token,Principal,ChannelRegistry}" not found. The local server_vendor has core-api 1.6.55, older than the required ^1.6.69. Unrelated to this change. New tests (ApiCacheDependenciesTest, ApiCacheInvalidationTest): 11 passed after the release review. Run against the unfixed source, they fail (5 errors: property missing; 4 failures: version not advanced). Touched existing files run in isolation: CatalogPriceOverrideTest 4/4, FoodTruckCatalogsTest 2/2, ProductBehaviorTest 9/9, CommandContractsTest 17/17. StorefrontModelContractsTest has 1 isolation-only error ("no such table: catalogs"), identical on base, and passes in the full suite. php-cs-fixer --dry-run on the 24 changed files: 2 findings, both on lines already on base (an alignment in CatalogPriceOverrideTest, import order and a docblock in v1/CheckoutController); left alone. phpstan (level max, no larastan) on the touched src files: error set identical to base.The tests use only APIs in released core-api (no
dependencyTables/getApiCacheDependencies):$model->{$segment}()->getRelated()), checking each segment is a non-morphToRelationand each single-segment table entry is a real storefront table.api_query_version:{table}:{company}key (table-wide for the cart purge).Documentation Impact
fleetbase/fleetbase.ioAPI Reference Impact
fleetbase/postmanAPI reference notes: response shapes, endpoints and validation are unchanged; only cache freshness changes.
Documentation Notes
The extension caching docs in
fleetbase/fleetbase.ioshould cover two things once core-api#301 is released. Where it belongs depends on what #301 documents in core, so it is flaggedneeds-docsrather than written here.$apiCacheDependencies.ApiModelCache::invalidateModelCache()/invalidateApiCacheManually().Risk
CatalogProductgainsHasApiModelBehavior. It now fires invalidation on create/update/delete and gains the trait's query helpers, the same as the sibling pivots. Nothing else about the pivot changes.discardFailedCaptureonly gains a cache invalidation after the existing delete, inside the existing try/catch. Payment and customer data handling is unchanged, but it is in the checkout path, so it's flagged for human review.Screenshots / Recordings
n/a (server-only change)