From f58aa994ed187caf4946af664c4116aacf58e340 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 20 Feb 2026 19:58:32 +0000 Subject: [PATCH] Add fetch_cached() client-side ORM cache with SPA auto-reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/RSpade/Core/Js/Rsx_Js_Model.js | 84 +++++++++++++++++- app/RSpade/Core/SPA/Spa.js | 3 + app/RSpade/man/model_fetch.txt | 137 ++++++++++++++++++----------- docs/CLAUDE.dist.md | 2 + 4 files changed, 173 insertions(+), 53 deletions(-) diff --git a/app/RSpade/Core/Js/Rsx_Js_Model.js b/app/RSpade/Core/Js/Rsx_Js_Model.js index bebabbf90..421a3ca36 100755 --- a/app/RSpade/Core/Js/Rsx_Js_Model.js +++ b/app/RSpade/Core/Js/Rsx_Js_Model.js @@ -16,6 +16,10 @@ * @Instantiatable */ class Rsx_Js_Model { + // Client-side fetch cache: Map<"ModelName:id", Promise> + // Reset on SPA navigation, manual reset via orm_cache_reset() + static _fetch_cache = new Map(); + /** * Constructor - Initialize model instance with data * @@ -77,7 +81,11 @@ class Rsx_Js_Model { const response = await Orm_Controller.fetch({ model: modelName, id: id }); // Response is already hydrated by Ajax layer (Ajax.js calls _instantiate_models_recursive) - // Just return the response directly + + // Warm fetch cache for subsequent fetch_cached() calls + const cache_key = `${modelName}:${id}`; + Rsx_Js_Model._fetch_cache.set(cache_key, Promise.resolve(response)); + return response; } @@ -100,9 +108,83 @@ class Rsx_Js_Model { // Pass or_null flag to get null instead of exception const response = await Orm_Controller.fetch({ model: modelName, id: id, or_null: true }); + // Warm fetch cache on successful fetch (don't cache nulls) + if (response !== null) { + const cache_key = `${modelName}:${id}`; + Rsx_Js_Model._fetch_cache.set(cache_key, Promise.resolve(response)); + } + return response; } + /** + * Fetch record using in-memory cache + * + * Returns cached result if available, otherwise calls fetch() and caches the result. + * Stores promises so concurrent calls for the same model/id share a single backend + * request. Cache automatically resets on SPA navigation. + * + * Intended for non-critical display data (names, labels, log history) where freshness + * is not essential. Do NOT use for data that must reflect recent edits. + * + * @param {number} id - ID to fetch + * @returns {Promise} - Model instance (from cache or fresh fetch) + * @throws {Error} - If record not found or access denied + */ + static async fetch_cached(id) { + const CurrentClass = this; + const modelName = CurrentClass.__MODEL || CurrentClass.name; + const cache_key = `${modelName}:${id}`; + + if (Rsx_Js_Model._fetch_cache.has(cache_key)) { + return Rsx_Js_Model._fetch_cache.get(cache_key); + } + + // Store the promise itself - concurrent calls for same model/id + // await the same promise (single backend request) + const promise = CurrentClass.fetch(id); + Rsx_Js_Model._fetch_cache.set(cache_key, promise); + + try { + const result = await promise; + return result; + } catch (e) { + // Failed fetches removed from cache so next attempt retries + Rsx_Js_Model._fetch_cache.delete(cache_key); + throw e; + } + } + + /** + * Reset the ORM fetch cache + * + * Called automatically on SPA navigation. Can also be called manually + * after save operations to ensure subsequent fetch_cached() calls + * retrieve fresh data. + * + * @param {string} [model_name] - Clear specific model only (all IDs) + * @param {number} [id] - Clear specific model+id (requires model_name) + */ + static orm_cache_reset(model_name, id) { + if (!model_name) { + Rsx_Js_Model._fetch_cache.clear(); + return; + } + + if (id !== undefined) { + Rsx_Js_Model._fetch_cache.delete(`${model_name}:${id}`); + return; + } + + // Clear all entries for specific model + const prefix = `${model_name}:`; + for (const key of Rsx_Js_Model._fetch_cache.keys()) { + if (key.startsWith(prefix)) { + Rsx_Js_Model._fetch_cache.delete(key); + } + } + } + /** * Get the PHP model class name * Used internally for API calls diff --git a/app/RSpade/Core/SPA/Spa.js b/app/RSpade/Core/SPA/Spa.js index a86b13c4b..a465a76d3 100755 --- a/app/RSpade/Core/SPA/Spa.js +++ b/app/RSpade/Core/SPA/Spa.js @@ -585,6 +585,9 @@ class Spa { // (Browser refresh scroll is handled separately by Rsx._restore_scroll_on_refresh) Rsx.reset_pending_scroll(); + // Reset ORM fetch cache - cached data is per-page, not cross-navigation + Rsx_Js_Model.orm_cache_reset(); + // Trigger spa_dispatch_start event - allows cleanup before navigation // Use case: close modals, cancel pending operations, etc. Rsx.trigger('spa_dispatch_start', { url }); diff --git a/app/RSpade/man/model_fetch.txt b/app/RSpade/man/model_fetch.txt index 26de4d344..5709cc593 100755 --- a/app/RSpade/man/model_fetch.txt +++ b/app/RSpade/man/model_fetch.txt @@ -28,6 +28,7 @@ STATUS (as of 2025-11-23) Implemented: - fetch() and fetch_or_null() methods + - fetch_cached() with in-memory promise-based cache - Lazy relationship loading (belongsTo, hasMany, morphTo, etc.) - Enum properties on instances (BEM-style {column}__{field} pattern) - Static enum constants and accessor methods @@ -817,6 +818,72 @@ MODEL CONSTANTS - Constants inherited from parent classes - Framework constants from Rsx_Model_Abstract +CLIENT-SIDE FETCH CACHE + + In-memory cache for Model.fetch_cached() results. Stores promises keyed by + model name + id. Automatically resets on SPA navigation. + + API: + // Cached fetch - returns cached result or fetches from backend + const contact = await Contact_Model.fetch_cached(5); + + // Reset all cached data + Rsx_Js_Model.orm_cache_reset(); + + // Reset specific model (all IDs) + Rsx_Js_Model.orm_cache_reset('Contact_Model'); + + // Reset specific record + Rsx_Js_Model.orm_cache_reset('Contact_Model', 5); + + Behavior: + - First call: cache miss, calls fetch(), stores promise in cache + - Concurrent calls: same model/id awaits same promise (one request) + - Subsequent calls: returns cached promise (resolves immediately) + - Failed fetch: removed from cache, next call retries + - fetch() and fetch_or_null() warm the cache on success + - Cache stores promises, not raw values (enables concurrent dedup) + + Automatic Reset: + Cache clears at the start of every SPA navigation (Spa.dispatch). + This ensures each page starts with a fresh cache. + + Intended Use Cases: + - Displaying record names in action log history + - Showing labels for foreign key references in datagrids + - Resolving user names for display in activity feeds + - Any repeated lookups of the same record within a page + + NOT Intended For: + - Data that must reflect recent edits (use fetch() instead) + - Records loaded in forms or edit views + - Security-sensitive lookups where staleness is unacceptable + + Manual Reset After Saves: + When a save operation changes data that fetch_cached() may have + cached, explicitly invalidate after the save: + + const response = await Controller.save(form.vals()); + if (response.success) { + // Invalidate cached data for this record + Rsx_Js_Model.orm_cache_reset('Contact_Model', response.id); + } + + Without manual invalidation, fetch_cached() returns stale data + until the next SPA navigation resets the cache. + + Cache Warming: + Direct fetch() and fetch_or_null() calls populate the cache on + success. This means data loaded via fetch() is available to + subsequent fetch_cached() calls without a second backend request: + + // on_load() fetches fresh data + this.data.contact = await Contact_Model.fetch(this.args.id); + + // Later in same page, another component uses cached result + const contact = await Contact_Model.fetch_cached(this.args.id); + // ^ resolves immediately from cache, no backend call + =============================================================================== FUTURE DEVELOPMENT =============================================================================== @@ -925,24 +992,21 @@ JQHTML CACHE INTEGRATION (planned, Phase 2, low priority) Integration with jqhtml's component caching for instant first renders. Visual polish feature - avoids brief loading indicators, not a performance gain. + Note: The client-side fetch cache (fetch_cached / orm_cache_reset) is now + implemented. This todo covers the additional step of integrating that cache + with jqhtml's component render pipeline to enable synchronous cache hits + during on_load(), eliminating loading indicators for previously-seen data. + Problem: - jqhtml caches on_load results for instant duplicate component renders - First render still shows loading indicator while fetching - - If ORM data already cached from prior fetch, loading indicator unnecessary + - If ORM data already cached via fetch_cached(), indicator unnecessary Solution: - - Provide jqhtml with mock data fetch functions during first render - - Mock functions check ORM cache, return cached data if available - - If cache hit: on_load completes synchronously, no loading indicator - - If cache miss: falls back to normal async fetch with loading indicator - - Example Scenario: - // User views Contact #5, data cached - const contact = await Contact_Model.fetch(5); - - // User navigates away, then back to same contact - // Without integration: loading indicator shown briefly - // With integration: cached data used, renders instantly + - During component on_load(), fetch_cached() already returns instantly + for previously-fetched records (cache warmed by prior fetch() calls) + - Further integration: detect synchronous cache hits and skip loading + indicator entirely when all on_load() data resolves from cache Implementation Notes: - ORM maintains client-side cache of fetched records @@ -1018,54 +1082,23 @@ BATCH SECURITY OPTIMIZATION (todo) Required before relationship fetching is considered production-ready for models with large relationship sets. -OPT-IN FETCH CACHING (todo) +SERVER-SIDE FETCH CACHE (todo) Current Limitation: When a model's fetch() method returns an array (for augmented data), relationship fetching must call the database twice: once via fetch() to verify access, and once via find() to get the actual Eloquent model for relationship method calls. - Proposed Solution - Request-Scoped Cache: - Implement opt-in caching of fetch() results scoped to the current - page/action lifecycle. Cache automatically invalidates on navigation - or form submission. + Proposed Solution: + Request-scoped PHP-side cache of fetch() results. Individual fetch() + calls check cache before database query. Relationship traversal + benefits from prefetched parent records. - Cache Scope: - - Traditional pages: Single page instance (cache lives until navigation) - - SPA applications: Single SPA action (cache resets on action navigation) - - Cache Invalidation Events: - 1. Page navigation (traditional or SPA) - 2. Rsx_Form submission (any form submit clears cache) - 3. Developer-initiated (explicit cache clear call) - - Opt-In API (TBD): - // Enable caching for a model - class Project_Model extends Rsx_Model_Abstract { - protected static $fetch_cache_enabled = true; - } - - // Manual invalidation - Project_Model.clear_fetch_cache(); // Clear single model cache - Rsx_Js_Model.clear_all_fetch_caches(); // Clear all model caches - - Implementation Notes: - - Cache keyed by model class + record ID - - Cache stores the raw fetch() result (model or array) - - For array results, also cache the underlying Eloquent model - - Relationship fetching checks cache before database query - - SPA integration via Spa.on('action:change') event - - Form integration via Rsx_Form.on('submit') event - - Benefits: - - Eliminates duplicate database queries for relationship fetching - - Enables instant re-renders when returning to cached records - - Predictable invalidation tied to user actions - - Opt-in prevents unexpected caching behavior + Note: Client-side caching is now implemented via fetch_cached(). + See CLIENT-SIDE FETCH CACHE section above. This todo is for the + separate concern of server-side PHP query deduplication. Implementation Priority: Medium - Required before relationship fetching is considered production-ready - for models where fetch() returns augmented arrays. PAGINATED RELATIONSHIP RESULTS (todo, low priority) Current Limitation: diff --git a/docs/CLAUDE.dist.md b/docs/CLAUDE.dist.md index 22d1ba6bd..214736113 100644 --- a/docs/CLAUDE.dist.md +++ b/docs/CLAUDE.dist.md @@ -826,6 +826,8 @@ const tasks = await project.tasks(); // hasMany → Model[] **fetch() is for SECURITY, not aliasing**: The `fetch()` method exists to remove private data users shouldn't see. NEVER alias enum properties (e.g., `type_label` instead of `type_id__label`) or format dates server-side. Use the full BEM-style names and format dates on client with `Rsx_Date`/`Rsx_Time`. +**Cached Fetch**: `Model.fetch_cached(id)` — in-memory cache for non-critical display data (names, labels, log history). Cache stores promises so concurrent calls share one request. `fetch()` and `fetch_or_null()` warm the cache. Resets automatically on SPA navigation. Manual reset: `Rsx_Js_Model.orm_cache_reset()`, `Rsx_Js_Model.orm_cache_reset('Model_Name')`, `Rsx_Js_Model.orm_cache_reset('Model_Name', id)`. Do NOT use for data that must be fresh after edits. + Details: `php artisan rsx:man model_fetch` ### Migrations