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:
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user