Add fetch_cached() client-side ORM cache with SPA auto-reset

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2026-02-20 19:58:32 +00:00
parent b5eb27a827
commit f58aa994ed
4 changed files with 173 additions and 53 deletions

View File

@@ -16,6 +16,10 @@
* @Instantiatable
*/
class Rsx_Js_Model {
// Client-side fetch cache: Map<"ModelName:id", Promise<model>>
// 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

View File

@@ -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 });

View File

@@ -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:

View File

@@ -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