Fix code quality violations and enhance ROUTE-EXISTS-01 rule

Implement JQHTML function cache ID system and fix bundle compilation
Implement underscore prefix for system tables
Fix JS syntax linter to support decorators and grant exception to Task system
SPA: Update planning docs and wishlists with remaining features
SPA: Document Navigation API abandonment and future enhancements
Implement SPA browser integration with History API (Phase 1)
Convert contacts view page to SPA action
Convert clients pages to SPA actions and document conversion procedure
SPA: Merge GET parameters and update documentation
Implement SPA route URL generation in JavaScript and PHP
Implement SPA bootstrap controller architecture
Add SPA routing manual page (rsx:man spa)
Add SPA routing documentation to CLAUDE.md
Phase 4 Complete: Client-side SPA routing implementation
Update get_routes() consumers for unified route structure
Complete SPA Phase 3: PHP-side route type detection and is_spa flag
Restore unified routes structure and Manifest_Query class
Refactor route indexing and add SPA infrastructure
Phase 3 Complete: SPA route registration in manifest
Implement SPA Phase 2: Extract router code and test decorators
Rename Jqhtml_Component to Component and complete SPA foundation setup

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-19 17:48:15 +00:00
parent 77b4d10af8
commit 9ebcc359ae
4360 changed files with 37751 additions and 18578 deletions

View File

@@ -0,0 +1,306 @@
DATABASE SCHEMA ARCHITECTURE
============================
OVERVIEW
--------
This document describes the RSpade database schema organization and the architectural
principles governing how framework and application data is stored.
Date: 2024-11-18
Status: Active framework architecture documentation
TABLE NAMING CONVENTIONS
-------------------------
RSpade uses table name prefixes to distinguish between different layers of the system:
1. SYSTEM TABLES (underscore prefix: _tablename)
- Internal framework infrastructure
- Managed entirely by framework subsystems
- NOT meant to be accessed directly by application developers
- May use direct DB::table() queries (exempt from ORM requirement)
- Implementation details hidden from application layer
2. CORE TABLES (no prefix: tablename)
- Built-in application infrastructure
- Provided by framework but application-facing
- Developers query, extend, and build upon these
- MUST use ORM models (DB::table() prohibited)
- Part of the developer-facing API
3. APPLICATION TABLES (no prefix: tablename)
- Created by developers for their specific application
- Business domain entities
- MUST use ORM models (DB::table() prohibited)
- Not shipped with framework (starter template only)
DATABASE ACCESS RULES
----------------------
The underscore prefix has enforcement implications:
SYSTEM TABLES (_tablename):
- Direct queries allowed: DB::table('_task_queue')->where(...)->get()
- No ORM model required
- Code quality checker (PHP-DB-01) automatically skips these
- Framework code optimizes for performance over abstraction
CORE & APPLICATION TABLES (tablename):
- Direct queries PROHIBITED
- ORM model REQUIRED
- Code quality checker enforces this rule
- Example: Client_Model::where(...)->get()
CURRENT SYSTEM TABLES
----------------------
_migrations
Purpose: Laravel migration tracking system
Managed by: Laravel framework migration subsystem
Records: Migration batch history, execution timestamps
_task_queue
Purpose: Background job queue and scheduled task execution
Managed by: RSpade Task system (Task.php, Task_Instance.php)
Records: Queued jobs, scheduled tasks, execution logs, status tracking
Schema: class, method, params, status, logs, scheduled_for, worker_pid
_file_storage
Purpose: Content-addressable blob storage backend
Managed by: RSpade File attachment system
Records: Deduplicated file blobs indexed by SHA-256 hash
Implementation: Single-instance storage pattern - multiple attachments can
reference the same blob. Developers never access this directly.
_file_attachments
Purpose: Polymorphic file attachment tracking
Managed by: RSpade File attachment API
Records: Attachment metadata, relationships, dimensions, session tracking
Schema: key (UUID), file_storage_id (FK), fileable_type/id (polymorphic),
fileable_category, site_id, session_id
Developer API: Attachment methods, not direct table access
_flash_alerts
Purpose: Transient flash message queue
Managed by: Flash_Alert system
Records: Session-scoped UI messages (success, error, warning, info)
Lifecycle: Created on one request, displayed on next, then deleted
Schema: session_id, type_id, message, timestamps
_search_indexes
Purpose: Full-text search index storage
Managed by: RSpade Search system
Records: Extracted searchable content from polymorphic entities
Schema: indexable_type/id (polymorphic), content (full-text), metadata (JSON),
extraction_method, language, site_id
Implementation: Automatic extraction from models, search API queries this
CURRENT CORE TABLES
--------------------
sites
Purpose: Multi-tenancy root table
Managed by: Multi-tenant subsystem
Records: Tenant organizations, each with isolated data space
Usage: Developers query to show "available sites", build tenant switchers
Schema: name, domain, settings, active status
users
Purpose: User account storage
Managed by: Authentication subsystem
Records: User credentials, profile data, authentication state
Usage: Developers extend with additional fields, query for user listings
Schema: email, password_hash, name, site_id (FK), active status
login_users
Purpose: Authentication session/token tracking
Managed by: RsxAuth system
Records: Active login sessions with 365-day persistence
Usage: Developers query for "active sessions", implement logout-all
Schema: user_id (FK), session_token, ip_address, last_activity, expires_at
user_profiles
Purpose: Extended user profile information
Managed by: User management system
Records: Additional user data beyond core authentication
Usage: Developers customize profile fields for their application
Schema: user_id (FK), bio, avatar, preferences (JSON), custom fields
user_verifications
Purpose: Email verification and password reset token storage
Managed by: Authentication subsystem
Records: Temporary verification codes with expiration
Lifecycle: Created on request, validated once, then deleted or expired
Schema: user_id (FK), token, type (email_verify, password_reset), expires_at
user_invites
Purpose: User invitation system
Managed by: User invitation subsystem
Records: Pending invitations with expiration (48 hours default)
Lifecycle: Created by admin, accepted by invitee, then marked used or expired
Schema: site_id (FK), email, token, invited_by (FK), expires_at, accepted_at
ip_addresses
Purpose: IP address tracking and audit trail
Managed by: Security/audit subsystem
Records: IP addresses associated with user actions
Usage: Developers query for login history, security audit reports
Schema: ip_address, user_id (FK), action_type, created_at
file_attachments (DEPRECATED - will become _file_attachments)
See SYSTEM TABLES section above. Currently core, moving to system.
CURRENT APPLICATION TABLES
---------------------------
These tables are part of the starter template demonstration application.
Developers typically replace these with their own domain entities.
clients
Purpose: Demo business entity - client/customer records
Business domain: B2B SaaS starter template
Schema: name, email, phone, address, site_id (FK), status
client_departments
Purpose: Demo organizational structure
Business domain: Client organization hierarchy
Schema: client_id (FK), name, parent_id (FK for nested structure)
contacts
Purpose: Demo contact management
Business domain: People associated with clients
Schema: client_id (FK), name, email, phone, title, site_id (FK)
projects
Purpose: Demo project tracking
Business domain: Client projects/engagements
Schema: client_id (FK), name, description, status, priority, due_date
tasks (NOTE: Confusing name - different from _task_queue!)
Purpose: Demo user TODO/task list
Business domain: User task management
Schema: title, description, taskable_type/id (polymorphic), status, priority,
assigned_to_user_id (FK), due_date, site_id (FK)
WARNING: Name collision with _task_queue (background jobs). Consider renaming to
user_task_queue or todo_items in future versions.
demo_products
Purpose: Demo product catalog
Business domain: Product/service offerings
Schema: name, description, price, sku, category, active status
countries
Purpose: Geographic reference data
Business domain: International address support
Records: ISO country codes and names
Note: Reference data table, could be considered infrastructure
regions
Purpose: Geographic subdivision reference data
Business domain: State/province/region for addresses
Records: Country subdivisions
Note: Reference data table, could be considered infrastructure
ARCHITECTURAL PRINCIPLES
-------------------------
1. SEPARATION OF CONCERNS
System tables implement framework features invisibly. Core tables provide
developer-facing infrastructure. Application tables express business domain.
2. ABSTRACTION BOUNDARIES
System table schemas are implementation details. Core table schemas are API
contracts. System tables can change without migration pain if APIs remain stable.
3. PERFORMANCE VS CONSISTENCY
System tables prioritize performance (direct queries, denormalization OK).
Core/Application tables prioritize data integrity (ORM, relationships, validation).
4. MIGRATION STRATEGY
All migrations run chronologically when `php artisan migrate` is executed,
regardless of whether they affect system, core, or application tables.
System tables: Migrations written and shipped by framework developers
Core tables: Migrations written and shipped by framework developers
Application tables: Migrations written by application developers
When a developer does `git pull` and new framework migrations exist, they
run alongside application migrations in date order when migrate is executed.
5. DOCUMENTATION LEVEL
System tables: Internal documentation only
Core tables: Full API documentation required
Application tables: Developer-documented
FUTURE CONSIDERATIONS
---------------------
Planned Changes:
- Rename 'migrations' to '_migrations' (Laravel supports configuring this)
- Move 'file_attachments' to '_file_attachments' (pure system implementation detail)
- Consider renaming 'tasks' to 'user_task_queue' or 'todo_items' to avoid confusion with '_task_queue'
Potential System Tables:
- _cache (if implementing database cache driver)
- _jobs (if using database queue driver - currently using '_task_queue')
- _notifications (if implementing notification queue)
- _websocket_connections (if implementing WebSocket presence tracking)
MIGRATION MECHANICS
-------------------
When renaming a table to add underscore prefix:
1. Create migration to rename table
2. Update all Model classes: protected $table = '_newtablename';
3. Update direct queries in framework code
4. Update foreign key references in other tables (if any)
5. Update seeders, factories, tests
6. Run code quality checker to find missed references
7. Test all affected subsystems
Example migration:
Schema::rename('file_storage', '_file_storage');
The underscore prefix is purely a naming convention - database engines treat
these identically to non-prefixed tables. The convention exists for developer
clarity and code quality enforcement.
DEVELOPER GUIDANCE
------------------
When creating new tables, ask:
Q: Will application developers query this directly?
YES → Core/Application table (no prefix)
NO → System table (underscore prefix)
Q: Is this an implementation detail of a framework subsystem?
YES → System table (underscore prefix)
NO → Core/Application table (no prefix)
Q: Would changing the schema break the developer-facing API?
YES → Core table (no prefix) - schema is part of the API contract
NO → System table (underscore prefix) - implementation detail
Examples:
- User login sessions? System (_login_sessions) - API is RsxAuth methods
- User accounts? Core (users) - developers extend and query this
- Search index? System (_search_indexes) - API is Search::query()
- Client records? Application (clients) - business domain entity
CONCLUSION
----------
The underscore prefix system creates a clear architectural boundary between
framework implementation details and developer-facing data structures. This
enables the framework to optimize system tables for performance while maintaining
API stability, and helps developers understand which tables are theirs to use
versus which are managed by framework subsystems.
For questions or clarification, consult:
- /system/app/RSpade/Core/Database/CLAUDE.md - Database subsystem documentation
- /system/app/RSpade/Core/Task/CLAUDE.md - Task system documentation
- /system/app/RSpade/Core/Files/CLAUDE.md - File attachment documentation
- /system/app/RSpade/CodeQuality/CLAUDE.md - Code quality rules including PHP-DB-01
---
Document maintained by: Framework development team
Last updated: 2024-11-18

View File

@@ -234,41 +234,69 @@ EVENT BINDING (⚠️ VERIFY FUNCTIONALITY)
⚠️ TODO: Verify @event binding syntax is functional in current jqhtml version.
THIS.ARGS VS THIS.DATA
Two distinct data sources in components:
Two distinct data sources with strict lifecycle rules:
this.args - Component Input Parameters:
- Source: Attributes from component invocation ($attr=value)
- When Set: During component construction
- Purpose: Configuration and input parameters
- Mutability: Treat as read-only
- Examples: User ID, theme, callback functions
this.args - Component State (what to load):
- Source: Passed from parent via $attr=value
- Purpose: Component state that determines what on_load() fetches
- Mutability: Modifiable in all methods EXCEPT on_load()
- Usage: Page numbers, filters, sort order, configuration
- Examples: this.args.page, this.args.filter, this.args.user_id
this.data - Async Loaded Data:
- Source: on_load() lifecycle method
- When Set: During load phase (after render, before ready)
- Purpose: Dynamic data from APIs or computations
- Mutability: Can be modified, triggers re-render
this.data - Loaded Data (from APIs):
- Source: Set in on_load() lifecycle method
- Purpose: Data fetched from APIs based on this.args
- Mutability: ONLY modifiable in on_create() and on_load()
- Freeze Cycle: Frozen after on_create(), unfrozen during on_load(),
frozen again after on_load() completes
- Initial State: Empty object {} on first render
- Examples: API responses, processed data, cached values
- Examples: API responses, fetched records, computed results
Lifecycle Restrictions (ENFORCED):
- on_create(): Can modify this.data (set defaults)
- on_load(): Can ONLY access this.args and this.data
Cannot access this.$, this.$id(), or any other properties
Can modify this.data freely
- on_ready() / event handlers: Can modify this.args, read this.data
CANNOT modify this.data (frozen)
State Management Pattern:
class Product_List extends Jqhtml_Component {
on_create() {
// Set default state for on_load()
this.args.filter = this.args.filter || 'all';
this.args.page = this.args.page || 1;
// Set default data for template
this.data.products = [];
this.data.loading = false;
}
Example usage:
class UserCard extends Jqhtml_Component {
async on_load() {
// this.data starts as {}
const userId = this.args.userId; // From $user_id=...
// Read state from this.args
const filter = this.args.filter;
const page = this.args.page;
// Load data and store in this.data
this.data = await fetch(`/api/users/${userId}`)
.then(r => r.json());
// Triggers automatic re-render with populated data
// Fetch and set this.data
this.data = await Product_Controller.list({
filter: filter,
page: page
});
}
on_ready() {
console.log(this.data.name); // Loaded data
console.log(this.args.theme); // Input parameter
// Modify state, then reload
this.$id('filter_btn').on('click', () => {
this.args.filter = 'active'; // Change state
this.reload(); // Re-fetch with new state
});
}
}
Error Messages:
Violations throw runtime errors with detailed fix suggestions.
Example: "Cannot modify this.data outside of on_create() or on_load()."
COMMON PATTERNS AND PITFALLS
Correct usage examples:
<!-- Pass object reference -->
@@ -299,20 +327,8 @@ COMMON PATTERNS AND PITFALLS
}
CONTROL FLOW AND LOOPS
Templates support full JavaScript control flow with two syntax styles:
Templates support full JavaScript control flow using brace syntax:
Colon Syntax (PHP-like):
<% if (this.data.show): %>
<div>Visible content</div>
<% else: %>
<div>Hidden content</div>
<% endif; %>
<% for (let item of this.data.items): %>
<li><%= item.name %></li>
<% endfor; %>
Brace Syntax (JavaScript-like):
<% if (this.data.show) { %>
<div>Visible content</div>
<% } else { %>
@@ -385,12 +401,15 @@ COMPONENT LIFECYCLE
- Parent completes before children
4. on_load() (bottom-up, siblings in parallel, CAN be async)
- Load async data
- Load async data based on this.args
- ONLY access this.args and this.data (RESTRICTED)
- CANNOT access this.$, this.$id(), or any other properties
- ONLY modify this.data - NEVER DOM
- NO child component access
- Siblings at same depth execute in parallel
- Children complete before parent
- If this.data changes, triggers automatic re-render
- Runtime enforces access restrictions with clear errors
5. on_ready() (bottom-up)
- All children guaranteed ready
@@ -737,14 +756,14 @@ DOM CLASS CONVENTION
All instantiated jqhtml components receive CSS classes on their
DOM elements:
- 'Jqhtml_Component' - All components
- 'Component' - All components
- Component name (e.g., 'User_Card') - For targeting
// Select all jqhtml components on the page
const components = $('.Jqhtml_Component');
const components = $('.Component');
// Check if an element is a jqhtml component
if ($element.hasClass('Jqhtml_Component')) {
if ($element.hasClass('Component')) {
// This is a jqhtml component
}
@@ -887,13 +906,13 @@ LIFECYCLE MANIPULATION METHODS
Method Summary (jqhtml v2.2.182+):
reload() - Calls on_load() then render() - always re-renders
reload() - Restore this.data to defaults, call on_load(), then render()
render() - Re-renders with full lifecycle (waits for children, calls on_ready)
redraw() - Alias for render()
reload()
Re-fetch data and re-render - Calls on_load() then render() with full
lifecycle. Always re-renders after loading data.
Re-fetch data and re-render - Restores this.data to on_create() state,
calls on_load() to fetch fresh data, then renders.
Usage:
class User_Card extends Jqhtml_Component {
@@ -901,16 +920,24 @@ LIFECYCLE MANIPULATION METHODS
await this.reload(); // Fetches fresh data
console.log('User data updated');
}
on_ready() {
this.$id('filter_btn').on('click', async () => {
this.args.filter = 'active'; // Update state
await this.reload(); // Re-fetch with new state
});
}
}
Behavior:
- Calls on_load() to fetch fresh data
- Restores this.data to snapshot from on_create()
- Calls on_load() to fetch fresh data based on current this.args
- Always re-renders (calls render() with full lifecycle)
- Waits for children and calls on_ready()
- Returns promise - await for completion
- Use when: Need fresh data from server/API
- Use when: this.args changed and need to re-fetch data
Lifecycle: on_load() → render() → on_ready() → trigger('ready')
Lifecycle: restore this.data → on_load() → render() → on_ready() → trigger('ready')
render()
Re-render component - Re-executes template with full lifecycle,
@@ -936,23 +963,9 @@ LIFECYCLE MANIPULATION METHODS
Note: redraw() is an alias for render()
reinitialize()
Full component reset - Restarts entire lifecycle from stage 0
(nuclear option).
Usage:
class Dashboard extends Jqhtml_Component {
async switch_user(new_user_id) {
this.args.user_id = new_user_id;
await this.reinitialize(); // Complete reset
}
}
Behavior:
- Stops current component state
- Re-runs entire lifecycle: render → on_render → create → load → ready
- Use when: Component needs complete rebuild
- Rare use case - usually reload() or render() sufficient
NOTE: reinitialize() method removed in v2.2.201.
Use reload() for re-fetching data (99% of cases).
For complete reset, remove component and create new instance.
stop()
Component stopping - Removes component and all children from DOM.
@@ -1035,9 +1048,26 @@ DOM UTILITIES
This is genuine jQuery - all methods work directly
this.$id(name)
Get element by scoped ID
Get scoped element as jQuery object
Example: this.$id('edit') gets element with $id="edit"
Returns jQuery object
Returns jQuery object, NOT component instance
this.id(name)
Get scoped child component instance directly
Example: this.id('my_component') gets component instance
Returns component instance, NOT jQuery object
CRITICAL: this.$id() vs this.id() distinction
- this.$id('foo') → jQuery object (for DOM manipulation)
- this.id('foo') → Component instance (for calling methods)
Common mistake:
const comp = this.id('foo').component(); // ❌ WRONG
const comp = this.id('foo'); // ✅ CORRECT
Getting component from jQuery:
const $elem = this.$id('foo');
const comp = $elem.component(); // ✅ CORRECT (jQuery → component)
this.data
Component data object

View File

@@ -12,7 +12,7 @@ DESCRIPTION
Key differences from Laravel:
- Laravel: route('user.profile', $user) using named routes
- RSX: Rsx::Route('User_Controller', 'profile', ['id' => $user->id])
- RSX: Rsx::Route('User_Controller::profile', ['id' => $user->id])
Benefits:
- No route name management required
@@ -22,24 +22,33 @@ DESCRIPTION
- Refactoring-safe (renaming controllers updates routes)
BASIC USAGE
Signature:
Rsx::Route($action, $params = null)
Rsx.Route(action, params = null)
Where:
- $action: Controller class, SPA action, or "Class::method"
Defaults to 'index' method if no :: present
- $params: Integer sets 'id', array/object provides named params
PHP Syntax:
use App\RSpade\Core\Rsx;
// Generate URLs (returns string directly)
$url = Rsx::Route('Demo_Index_Controller'); // /demo
$url = Rsx::Route('Demo_Index_Controller', 'show'); // /demo/show
$url = Rsx::Route('Demo_Index_Controller', 'show', ['id' => 123]); // /demo/123
$url = Rsx::Route('Demo_Index_Controller', 'show', 123); // /demo/123 (shorthand)
// Generate URLs (defaults to 'index' method)
$url = Rsx::Route('Demo_Index_Controller'); // /demo
$url = Rsx::Route('Demo_Index_Controller::show'); // /demo/show
$url = Rsx::Route('Demo_Index_Controller::show', 123); // /demo/123
$url = Rsx::Route('Demo_Index_Controller::show', ['id' => 123]); // /demo/123
// Use in redirects
return redirect(Rsx::Route('Demo_Index_Controller'));
JavaScript Syntax:
// Generate URLs (returns string directly)
const url = Rsx.Route('Demo_Index_Controller'); // /demo
const url = Rsx.Route('Demo_Index_Controller', 'show'); // /demo/show
const url = Rsx.Route('Demo_Index_Controller', 'show', {id: 123}); // /demo/123
const url = Rsx.Route('Demo_Index_Controller', 'show', 123); // /demo/123 (shorthand)
// Generate URLs (defaults to 'index' method)
const url = Rsx.Route('Demo_Index_Controller'); // /demo
const url = Rsx.Route('Demo_Index_Controller::show'); // /demo/show
const url = Rsx.Route('Demo_Index_Controller::show', 123); // /demo/123
const url = Rsx.Route('Demo_Index_Controller::show', {id: 123}); // /demo/123
// Use in navigation
window.location.href = Rsx.Route('Demo_Index_Controller');
@@ -143,17 +152,17 @@ ADVANCED PATTERNS
Complex Parameter Examples:
// Multiple parameters
#[Route('/api/v1/users/:company/:division/:id')]
$url = Rsx::Route('Api_V1_Users_Controller', 'show',
$url = Rsx::Route('Api_V1_Users_Controller::show',
['company' => 'acme', 'division' => 'sales', 'id' => 123]);
// Result: /api/v1/users/acme/sales/123
// Query parameters for extra values
$url = Rsx::Route('Demo_Controller', 'show',
$url = Rsx::Route('Demo_Controller::show',
['id' => 123, 'format' => 'json', 'include' => 'profile']);
// Result: /demo/123?format=json&include=profile
// Complex objects as parameters
$url = Rsx::Route('Demo_Controller', 'index',
$url = Rsx::Route('Demo_Controller',
['filter' => ['status' => 'active', 'type' => 'user']]);
// Result: /demo?filter[status]=active&filter[type]=user
@@ -230,12 +239,12 @@ COMMON PATTERNS
AJAX URL Generation:
// Generate URLs for AJAX calls
const apiUrl = Rsx.Route('Api_User_Controller', 'update', {id: userId});
const apiUrl = Rsx.Route('Api_User_Controller::update', {id: userId});
fetch(apiUrl, {method: 'POST', body: formData});
Form Action URLs:
// Generate form action URLs
<form action="<?= Rsx::Route('User_Profile_Controller', 'update') ?>" method="POST">
<form action="<?= Rsx::Route('User_Profile_Controller::update') ?>" method="POST">
Link Generation:
// Generate navigation links

View File

@@ -504,21 +504,31 @@ TROUBLESHOOTING
- Use has_session() to check without creating
GARBAGE COLLECTION
Expired Session Cleanup:
Sessions older than 365 days should be deleted periodically.
Automatic Session Cleanup:
Sessions are automatically cleaned up via scheduled task that runs
daily at 3 AM. No manual configuration required.
Create scheduled command:
php artisan make:command CleanupSessions
Cleanup Rules:
- Logged-in sessions (login_user_id set): Deleted after 365 days
- Anonymous sessions (login_user_id null): Deleted after 14 days
In handle() method:
$deleted = Session::cleanup_expired(365);
$this->info("Deleted $deleted expired sessions");
The cleanup uses last_active timestamp to determine session age.
Sessions are permanently deleted from the database.
Schedule in app/Console/Kernel.php:
$schedule->command('sessions:cleanup')->daily();
Implementation:
Service: Session_Cleanup_Service::cleanup_sessions()
Schedule: Daily at 3 AM (via #[Schedule] attribute)
Automatic: No cron configuration needed
The cleanup_expired() method deletes sessions where last_active
is older than the specified number of days.
Manual Trigger:
To manually run cleanup outside the schedule:
php artisan rsx:task:run Session_Cleanup_Service cleanup_sessions
This is useful for:
- Testing cleanup logic
- Emergency cleanup when disk space is low
- One-time cleanup after changing retention policies
SEE ALSO
rsx:man routing - Type-safe URL generation

688
app/RSpade/man/spa.txt Executable file
View File

@@ -0,0 +1,688 @@
SPA(3) RSX Framework Manual SPA(3)
NAME
spa - Single Page Application routing for authenticated areas
SYNOPSIS
PHP Bootstrap Controller (one per SPA module):
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
{
#[SPA]
#[Auth('Permission::authenticated()')]
public static function index(Request $request, array $params = [])
{
return rsx_view(SPA);
}
}
JavaScript Action (many per module):
@route('/contacts')
@layout('Frontend_Layout')
@spa('Frontend_Spa_Controller::index')
class Contacts_Index_Action extends Spa_Action {
async on_load() {
this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch();
}
}
DESCRIPTION
The RSX SPA system provides client-side routing for authenticated application
areas, enabling navigation without page reloads. Unlike traditional Laravel
views where each navigation triggers a full page load, SPA modules bootstrap
once and handle all subsequent navigation client-side through JavaScript
actions.
This approach is fundamentally different from traditional server-side routing:
- Traditional: Every navigation loads new HTML from server
- SPA: First request bootstraps application, subsequent navigation is client-side
Key differences from Laravel:
- Laravel: Full page reload per navigation, routes in routes/ files
- RSX: Bootstrap once, routes defined in JavaScript with @route() decorator
- Laravel: Separate frontend frameworks (Vue, React) require build tooling
- RSX: Integrated JQHTML component system, no external build required
- Laravel: API routes separate from view routes
- RSX: Controllers provide Ajax endpoints, actions consume them
Benefits:
- No page reloads after initial bootstrap
- Persistent layout across navigation
- Automatic browser history integration
- Same Rsx::Route() syntax for both traditional and SPA routes
- No frontend build tooling required
When to use:
- Authenticated areas (dashboards, admin panels)
- Applications with frequent navigation
- Features requiring persistent state
When to avoid:
- Public pages requiring SEO
- Simple static content
- Forms without complex interactions
SPA ARCHITECTURE
SPA Module Structure:
An SPA module consists of six component types:
1. PHP Bootstrap Controller (ONE per feature/bundle)
- Single entry point marked with #[SPA]
- Performs authentication check with failure/redirect
- Renders SPA bootstrap layout
- Referenced by all actions via @spa() decorator
- One #[SPA] per feature/bundle (e.g., /app/frontend, /app/root, /app/login)
- Bundles segregate code for security and performance:
* Save bandwidth by loading only needed features
* Reduce processing time by smaller bundle sizes
* Protect confidential code (e.g., root admin from unauthorized users)
- Typically one #[SPA] per feature at rsx/app/(feature)/(feature)_spa_controller::index
2. Feature Controllers (Ajax Endpoints Only)
- Provide data via #[Ajax_Endpoint] methods
- No #[SPA] or #[Route] methods
- Called by actions to fetch/save data
3. JavaScript Actions (MANY per module)
- Represent individual pages/routes
- Define route patterns with @route()
- Load data in on_load() lifecycle method
- Access URL parameters via this.args
4. Action Templates (.jqhtml)
- Render action content
- Standard JQHTML component templates
- Replace traditional Blade views
5. Layout Template (.jqhtml)
- Persistent wrapper around actions
- Must have element with $id="content"
- Persists across action navigation
6. Layout Class (.js)
- Extends Spa_Layout
- Optional on_action() hook for navigation tracking
Execution Flow:
First Request:
1. User navigates to SPA route (e.g., /contacts)
2. Dispatcher calls bootstrap controller
3. Bootstrap returns rsx_view(SPA) with window.rsxapp.is_spa = true
4. Client JavaScript discovers all actions via manifest
5. Router matches URL to action class
6. Creates layout on <body>
7. Creates action inside layout $id="content" area
Subsequent Navigation:
1. User clicks link or calls Spa.dispatch()
2. Router matches URL to action class
3. Destroys old action, creates new action
4. Layout persists - no re-render
5. No server request, no page reload
BOOTSTRAP CONTROLLER
Creating SPA Entry Point:
// /rsx/app/frontend/Frontend_Spa_Controller.php
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
{
#[SPA]
#[Auth('Permission::authenticated()')]
public static function index(Request $request, array $params = [])
{
return rsx_view(SPA);
}
}
Key Points:
- One bootstrap controller per feature/bundle
- #[SPA] attribute marks as SPA entry point
- #[Auth] performs server-side authentication with failure/redirect
- Must return rsx_view(SPA) - special constant
- All actions in the same bundle reference this via @spa() decorator
Bundle Segregation:
- Separate bundles for different features: /app/frontend, /app/root, /app/login
- Each bundle has its own #[SPA] bootstrap
- Benefits:
* Bandwidth: Users only download code for authorized features
* Performance: Smaller bundles = faster load times
* Security: Root admin code never sent to unauthorized users
- Example: Frontend users never receive root admin JavaScript/CSS
Location:
- Place at feature root: /rsx/app/{feature}/
- Not in subdirectories
- One per feature/bundle
- Naming: {Feature}_Spa_Controller::index
Multiple SPA Bootstraps:
Different features can have separate SPA bootstraps:
- /app/frontend/Frontend_Spa_Controller::index (regular users)
- /app/root/Root_Spa_Controller::index (root admins)
- /app/login/Login_Spa_Controller::index (authentication flow)
Each bootstrap controls access to its feature's actions via #[Auth].
FEATURE CONTROLLERS
Ajax Endpoint Pattern:
Feature controllers provide data endpoints only, no routes or SPA entry:
// /rsx/app/frontend/contacts/frontend_contacts_controller.php
class Frontend_Contacts_Controller extends Rsx_Controller_Abstract
{
#[Ajax_Endpoint]
public static function datagrid_fetch(Request $request, array $params = []): array
{
return Contacts_DataGrid::fetch($params);
}
#[Ajax_Endpoint]
public static function save(Request $request, array $params = [])
{
// Validation and save logic
$contact->save();
Flash_Alert::success('Contact saved successfully');
return [
'contact_id' => $contact->id,
'redirect' => Rsx::Route('Contacts_View_Action', $contact->id),
];
}
}
Guidelines:
- All methods are #[Ajax_Endpoint]
- No #[Route] methods in SPA feature controllers
- No #[SPA] methods (only in bootstrap controller)
- Return arrays/data, not views
- Use Flash_Alert with redirects for success messages
See Also:
ajax_error_handling(3) for error patterns
controller(3) for #[Ajax_Endpoint] details
JAVASCRIPT ACTIONS
Creating Actions:
// /rsx/app/frontend/contacts/Contacts_Index_Action.js
@route('/contacts')
@layout('Frontend_Layout')
@spa('Frontend_Spa_Controller::index')
class Contacts_Index_Action extends Spa_Action {
async on_load() {
this.data.contacts = await Frontend_Contacts_Controller.datagrid_fetch();
}
on_create() {
// Component initialization
}
on_ready() {
// DOM is ready, setup event handlers
this.$id('search').on('input', () => this.reload());
}
}
Decorator Syntax:
@route(pattern)
URL pattern with optional :param segments
Example: '/contacts/:id', '/users/:id/posts/:post_id'
@layout(class_name)
Layout class to render within
Example: 'Frontend_Layout', 'Dashboard_Layout'
@spa(controller::method)
References bootstrap controller
Example: 'Frontend_Spa_Controller::index'
@title(page_title)
Browser page title for this action (optional, clears if not present)
Alternatively, set dynamically in on_ready() via document.title = "value"
Example: @title('Contacts - RSX')
URL Parameters:
Parameters from route pattern and query string available in this.args:
// URL: /contacts/123?tab=history
@route('/contacts/:id')
class Contacts_View_Action extends Spa_Action {
on_create() {
console.log(this.args.id); // "123"
console.log(this.args.tab); // "history"
}
}
Lifecycle Methods:
on_create() Component construction, setup this.state
on_load() Fetch data, populate this.data (read-only this.args)
on_render() Template rendering
on_ready() DOM ready, setup event handlers
See Also:
jqhtml(3) for complete lifecycle documentation
ACTION TEMPLATES
Creating Templates:
Action templates are standard JQHTML components:
<!-- /rsx/app/frontend/contacts/Contacts_Index_Action.jqhtml -->
<Define:Contacts_Index_Action tag="main">
<h1>Contacts</h1>
<div class="contacts-list">
<% for (let contact of this.data.contacts) { %>
<div class="contact-item">
<a href="<%= Rsx.Route('Contacts_View_Action', {id: contact.id}) %>">
<%= contact.name %>
</a>
</div>
<% } %>
</div>
</Define:Contacts_Index_Action>
Key Points:
- Use Rsx.Route() for all URLs, never hardcode
- Access data via this.data (loaded in on_load)
- Use standard JQHTML template syntax
- Can reference other components
LAYOUTS
Layout Template:
Layout provides persistent wrapper around actions:
<!-- /rsx/app/frontend/Frontend_Layout.jqhtml -->
<Define:Frontend_Layout>
<div class="frontend-layout">
<header class="mb-3">
<h1>My App</h1>
<nav>
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
<a href="<%= Rsx.Route('Projects_Index_Action') %>">Projects</a>
</nav>
</header>
<main $id="content">
<!-- Actions render here -->
</main>
<footer class="mt-3">
<p>&copy; 2024 My Company</p>
</footer>
</div>
</Define:Frontend_Layout>
Requirements:
- Must have element with $id="content"
- Content area is where actions render
- Layout persists across navigation
Layout Class:
Optional JavaScript class for navigation tracking:
// /rsx/app/frontend/Frontend_Layout.js
class Frontend_Layout extends Spa_Layout {
on_action(url, action_name, args) {
// Called AFTER action component created and booted
// Called BEFORE action reaches on_ready()
// Can immediately access this.action properties
// Update active nav highlighting
this.$('.nav-link').removeClass('active');
this.$(`[data-action="${action_name}"]`).addClass('active');
// If you need to wait for action's full lifecycle:
// await this.action.ready();
}
}
on_action() Lifecycle Timing:
- Called after action component is created and booted
- Called before action reaches on_ready()
- Can immediately access this.action properties
- Use await this.action.ready() to wait for action's full loading
URL GENERATION
CRITICAL: All URLs must use Rsx::Route() or Rsx.Route(). Raw URLs like
"/contacts" will produce errors.
Signature:
Rsx::Route($action, $params = null)
Rsx.Route(action, params = null)
Where:
- $action: Controller class, SPA action, or "Class::method"
Defaults to 'index' method if no :: present
- $params: Integer sets 'id', array/object provides named params
PHP Syntax:
// Works for both controller routes and SPA actions
Rsx::Route('Contacts_Index_Action') // /contacts
Rsx::Route('Contacts_View_Action', 123) // /contacts/123
JavaScript Syntax:
// Works for both controller routes and SPA actions
Rsx.Route('Contacts_Index_Action') // /contacts
Rsx.Route('Contacts_View_Action', 123) // /contacts/123
Automatic Detection:
Rsx::Route() automatically detects whether the class is:
- PHP controller with #[Route] attribute
- JavaScript SPA action with @route() decorator
Query Parameters:
Extra parameters become query string:
Rsx::Route('Contacts_Index_Action', ['filter' => 'active', 'sort' => 'name'])
// Result: /contacts?filter=active&sort=name
NAVIGATION
Automatic Browser Integration:
- Clicking links to SPA routes triggers client-side navigation
- Browser back/forward buttons handled without page reload
- URL updates in address bar via pushState()
- Links to external or non-SPA routes perform full page load
Programmatic Navigation:
Spa.dispatch(url)
Navigate to URL programmatically
If URL is part of current SPA, uses client-side routing
If URL is external or different SPA, performs full page load
Example:
// Navigate within SPA
Spa.dispatch('/contacts/123');
// Navigate to external URL (full page load)
Spa.dispatch('https://example.com');
Use Spa.dispatch() instead of window.location for all navigation.
Accessing Active Components:
Spa.layout Reference to current layout component instance
Spa.action Reference to current action component instance
Example:
// From anywhere in SPA context
Spa.action.reload(); // Reload current action
Spa.layout.update_nav(); // Call layout method
FILE ORGANIZATION
Standard SPA Module Structure:
/rsx/app/frontend/
|-- Frontend_Spa_Controller.php # Bootstrap controller
|-- Frontend_Layout.js # Layout class
|-- Frontend_Layout.jqhtml # Layout template
|-- frontend_bundle.php # Bundle definition
|
|-- contacts/
| |-- frontend_contacts_controller.php # Ajax endpoints
| |-- Contacts_Index_Action.js # /contacts
| |-- Contacts_Index_Action.jqhtml
| |-- Contacts_View_Action.js # /contacts/:id
| `-- Contacts_View_Action.jqhtml
|
`-- projects/
|-- frontend_projects_controller.php
|-- Projects_Index_Action.js
`-- Projects_Index_Action.jqhtml
Naming Conventions:
- Bootstrap controller: {Module}_Spa_Controller.php
- Feature controllers: {feature}_{module}_controller.php
- Actions: {Feature}_{Action}_Action.js
- Templates: {Feature}_{Action}_Action.jqhtml
- Layout: {Module}_Layout.js and .jqhtml
EXAMPLES
Complete Contacts Module:
1. Bootstrap Controller:
// /rsx/app/frontend/Frontend_Spa_Controller.php
class Frontend_Spa_Controller extends Rsx_Controller_Abstract
{
#[SPA]
#[Auth('Permission::authenticated()')]
public static function index(Request $request, array $params = [])
{
return rsx_view(SPA);
}
}
2. Feature Controller:
// /rsx/app/frontend/contacts/frontend_contacts_controller.php
class Frontend_Contacts_Controller extends Rsx_Controller_Abstract
{
#[Ajax_Endpoint]
public static function fetch_all(Request $request, array $params = []): array
{
$contacts = Contact_Model::query()
->where('is_active', 1)
->orderBy('name')
->get();
return $contacts->toArray();
}
#[Ajax_Endpoint]
public static function save(Request $request, array $params = [])
{
$contact = Contact_Model::findOrNew($params['id'] ?? null);
$contact->fill($params);
$contact->save();
Flash_Alert::success('Contact saved');
return [
'contact_id' => $contact->id,
'redirect' => Rsx::Route('Contacts_View_Action', $contact->id),
];
}
}
3. List Action:
// /rsx/app/frontend/contacts/Contacts_Index_Action.js
@route('/contacts')
@layout('Frontend_Layout')
@spa('Frontend_Spa_Controller::index')
class Contacts_Index_Action extends Spa_Action {
async on_load() {
this.data.contacts = await Frontend_Contacts_Controller.fetch_all();
}
}
4. List Template:
<!-- /rsx/app/frontend/contacts/Contacts_Index_Action.jqhtml -->
<Define:Contacts_Index_Action tag="main">
<h1>Contacts</h1>
<% for (let contact of this.data.contacts) { %>
<div>
<a href="<%= Rsx.Route('Contacts_View_Action', {id: contact.id}) %>">
<%= contact.name %>
</a>
</div>
<% } %>
</Define:Contacts_Index_Action>
5. View Action with Parameters:
// /rsx/app/frontend/contacts/Contacts_View_Action.js
@route('/contacts/:id')
@layout('Frontend_Layout')
@spa('Frontend_Spa_Controller::index')
class Contacts_View_Action extends Spa_Action {
async on_load() {
const contact_id = this.args.id;
this.data.contact = await Contact_Model.fetch(contact_id);
}
}
6. Layout:
<!-- /rsx/app/frontend/Frontend_Layout.jqhtml -->
<Define:Frontend_Layout>
<div class="app">
<header>
<nav>
<a href="<%= Rsx.Route('Contacts_Index_Action') %>">Contacts</a>
</nav>
</header>
<main $id="content"></main>
</div>
</Define:Frontend_Layout>
SPA VS TRADITIONAL ROUTES
Architecture Comparison:
Traditional Route:
Every navigation:
1. Browser sends HTTP request to server
2. Controller executes, queries database
3. Blade template renders full HTML page
4. Server sends complete HTML to browser
5. Browser parses and renders new page
6. JavaScript re-initializes
7. User sees page flash/reload
SPA Route:
First navigation:
1. Browser sends HTTP request to server
2. Bootstrap controller renders SPA shell
3. JavaScript initializes, discovers actions
4. Router matches URL to action
5. Action loads data via Ajax
6. Action renders in persistent layout
Subsequent navigation:
1. Router matches URL to action (client-side)
2. New action loads data via Ajax
3. New action renders in existing layout
4. No server request, no page reload
5. Layout persists (header/nav/footer stay)
Code Comparison:
Traditional:
// Controller
#[Route('/contacts')]
public static function index(Request $request, array $params = []) {
$contacts = Contact_Model::all();
return rsx_view('Contacts_List', ['contacts' => $contacts]);
}
// Blade view
@extends('layout')
@section('content')
<h1>Contacts</h1>
@foreach($contacts as $contact)
<div>{{ $contact->name }}</div>
@endforeach
@endsection
SPA:
// Bootstrap (once)
#[SPA]
public static function index(Request $request, array $params = []) {
return rsx_view(SPA);
}
// Ajax endpoint
#[Ajax_Endpoint]
public static function fetch_all(Request $request, array $params = []): array {
return Contact_Model::all()->toArray();
}
// Action
@route('/contacts')
@spa('Frontend_Spa_Controller::index')
class Contacts_Index_Action extends Spa_Action {
async on_load() {
this.data.contacts = await Frontend_Contacts_Controller.fetch_all();
}
}
// Template
<Define:Contacts_Index_Action>
<h1>Contacts</h1>
<% for (let contact of this.data.contacts) { %>
<div><%= contact.name %></div>
<% } %>
</Define:Contacts_Index_Action>
TROUBLESHOOTING
SPA Not Initializing:
- Verify bootstrap controller has #[SPA] attribute
- Check controller returns rsx_view(SPA)
- Ensure bundle includes SPA directory
- Verify window.rsxapp.is_spa is true in HTML
Action Not Found:
- Check @route() decorator matches URL pattern
- Verify action extends Spa_Action
- Ensure action file included in bundle
- Run: php artisan rsx:manifest:build
Navigation Not Working:
- Verify using Rsx.Route() not hardcoded URLs
- Check @spa() decorator references correct bootstrap controller
- Ensure layout has $id="content" element
- Test Spa.dispatch() directly
Layout Not Persisting:
- Verify all actions in module use same @layout()
- Check layout template has $id="content"
- Ensure not mixing SPA and traditional routes
this.args Empty:
- Check route pattern includes :param for dynamic segments
- Verify URL matches route pattern exactly
- Remember query params (?key=value) also in this.args
Data Not Loading:
- Verify Ajax endpoint has #[Ajax_Endpoint]
- Check endpoint returns array, not view
- Ensure await used in on_load() for async calls
- Verify controller included in bundle
COMMON PATTERNS
Conditional Navigation:
// Navigate based on condition
if (user.is_admin) {
Spa.dispatch(Rsx.Route('Admin_Dashboard_Action'));
} else {
Spa.dispatch(Rsx.Route('User_Dashboard_Action'));
}
Reloading Current Action:
// Refresh current action data
Spa.action.reload();
Form Submission with Redirect:
async on_submit() {
const data = this.vals();
const result = await Controller.save(data);
if (result.errors) {
Form_Utils.apply_form_errors(this.$, result.errors);
return;
}
// Flash alert set server-side, redirect to view
Spa.dispatch(result.redirect);
}
Multiple Layouts:
// Different layouts for different areas
@layout('Admin_Layout')
class Admin_Users_Action extends Spa_Action { }
@layout('Frontend_Layout')
class Frontend_Contacts_Action extends Spa_Action { }
SEE ALSO
controller(3) - Controller patterns and Ajax endpoints
jqhtml(3) - Component lifecycle and templates
routing(3) - URL generation and route patterns
modals(3) - Modal dialogs in SPA context
ajax_error_handling(3) - Error handling patterns

267
app/RSpade/man/storage.txt Executable file
View File

@@ -0,0 +1,267 @@
RSX_STORAGE(1) RSpade Manual RSX_STORAGE(1)
NAME
Rsx_Storage - Scoped browser storage helper with automatic fallback
SYNOPSIS
// Session storage (cleared on tab close)
Rsx_Storage.session_set(key, value)
Rsx_Storage.session_get(key)
Rsx_Storage.session_remove(key)
// Local storage (persists across sessions)
Rsx_Storage.local_set(key, value)
Rsx_Storage.local_get(key)
Rsx_Storage.local_remove(key)
DESCRIPTION
Rsx_Storage provides safe, scoped access to browser sessionStorage and
localStorage with automatic handling of unavailable storage, quota exceeded
errors, and scope invalidation.
Key features:
- Automatic scoping by session, user, site, and build version
- Graceful degradation when storage unavailable (returns null)
- Automatic quota management (clears and retries when full)
- Scope validation (clears stale data on scope change)
- Developer-friendly key format for easy inspection
SCOPING SYSTEM
All storage keys are automatically scoped to prevent data leakage between:
- Different sessions (window.rsxapp.session_hash - hashed, non-reversible)
- Different users (window.rsxapp.user.id)
- Different sites (window.rsxapp.site.id)
- Different builds (window.rsxapp.build_key)
The scope is calculated by combining these values into a suffix:
session_hash_user_id_site_id_build_key
This scope is stored in the special key `_rsx_scope_key`. On page load, if
this key doesn't exist or doesn't match the current scope, all RSpade keys
are cleared and the new scope is stored.
The session_hash is a server-generated HMAC hash of the session cookie using
the application's encryption key, making it non-reversible while maintaining
consistency per session.
Example scope suffix:
a1b2c3d4e5f6_42_1_v2.1.0
└─ session ──┘ │ │ └─ build
user │
site
KEY FORMAT
Keys are stored with an `rsx::` namespace prefix, followed by the developer
key, followed by the scope suffix:
rsx::developer_key::scope_suffix
Example:
rsx::flash_queue::abc123def456_42_1_v2.1.0
The `rsx::` prefix serves two purposes:
1. Identifies RSpade keys for safe selective clearing (scope changes, quota)
2. Prevents collisions with other JavaScript libraries
This format allows developers to easily identify keys in browser developer
tools while maintaining proper scoping and coexistence with third-party
libraries. When inspecting storage, you'll see the `rsx::` prefix, your
original key name, and the scope suffix.
STORAGE AVAILABILITY
Rsx_Storage automatically detects if sessionStorage or localStorage are
available. Storage may be unavailable due to:
- Private browsing mode (some browsers block storage)
- Browser security settings
- Storage quota set to 0
- Browser bugs or incompatibilities
When storage is unavailable:
- set() operations are silently ignored (no error)
- get() operations return null
- remove() operations are silently ignored
This allows the application to continue functioning even when storage is
unavailable, as long as the stored data is non-critical.
SIZE LIMIT
Individual values larger than 1 MB are automatically rejected and not stored.
When attempting to store data > 1 MB:
- Operation is silently skipped (no error thrown)
- Console warning logged with actual size
- get() will return null for that key
This prevents quota issues and ensures browser storage remains performant.
If you need to store large data, consider:
- Storing server-side in database
- Using IndexedDB for large client-side data
- Splitting data into smaller chunks
QUOTA EXCEEDED HANDLING
When a set() operation fails due to quota exceeded (storage full), Rsx_Storage
automatically:
1. Clears only RSpade keys (keys starting with `rsx::`)
2. Preserves other libraries' data
3. Restores the _rsx_scope_key
4. Retries the set() operation once
If the retry also fails, the error is logged and the operation is abandoned.
This ensures the application continues functioning even when storage is full,
and minimizes impact on other JavaScript libraries sharing the same storage.
Only RSpade's previously stored data will be lost.
SCOPE INVALIDATION
Storage is automatically validated before every write operation. When the scope
changes, only RSpade keys (starting with `rsx::`) are cleared, preserving other
libraries' data.
Scope changes occur when:
- User logs in/out (user ID changes)
- User switches sites (site ID changes)
- Application is updated (build key changes)
- Session changes (rsx cookie changes)
This prevents stale data from one context bleeding into another context.
Example: User A logs in, stores preferences, logs out. User B logs in on the
same browser. User B sees clean RSpade storage, not User A's data. Other
libraries' data (e.g., analytics cookies, third-party preferences) remains
intact.
VOLATILE STORAGE WARNING
Browser storage is VOLATILE and can be cleared at any time. Never store data
critical to application functionality.
Storage can be lost due to:
- User clearing browser data
- Private browsing mode restrictions
- Quota exceeded errors (automatic clear + retry)
- Scope changes (logout, build update, session change)
- Browser storage unavailable
- Storage corruption or browser bugs
ONLY store data that is:
- Cached for performance (can be re-fetched)
- UI convenience state (non-essential)
- Transient messages (flash alerts, notifications)
If data is REQUIRED for the application to function correctly, store it
server-side in the database or PHP session.
EXAMPLES
Cache API response data (sessionStorage)
// Store cached data
const users = await fetch_users();
Rsx_Storage.session_set('cached_users', users);
// Retrieve cached data
let users = Rsx_Storage.session_get('cached_users');
if (!users) {
users = await fetch_users();
Rsx_Storage.session_set('cached_users', users);
}
Persist UI preferences (localStorage)
// Save theme preference
Rsx_Storage.local_set('theme', 'dark');
// Load theme preference
const theme = Rsx_Storage.local_get('theme') || 'light';
apply_theme(theme);
Store flash alert queue (sessionStorage)
// Queue persists across page navigation
Rsx_Storage.session_set('flash_queue', messages);
// Restore queue on next page
const messages = Rsx_Storage.session_get('flash_queue') || [];
Complex data structures
// Automatically JSON serialized
Rsx_Storage.local_set('user_prefs', {
theme: 'dark',
sidebar_collapsed: true,
recent_items: [1, 2, 3]
});
// Automatically JSON parsed
const prefs = Rsx_Storage.local_get('user_prefs');
console.log(prefs.theme); // 'dark'
Handling unavailable storage
// Always check for null (storage unavailable or key not found)
const cached = Rsx_Storage.session_get('data');
if (cached) {
use_cached_data(cached);
} else {
fetch_fresh_data();
}
Removing stale data
// Clean up when no longer needed
Rsx_Storage.session_remove('temp_data');
SESSIONSTORAGE VS LOCALSTORAGE
sessionStorage:
- Cleared when tab/window closes
- Not shared across tabs/windows
- Use for: temporary data, current session state, flash messages
localStorage:
- Persists across browser sessions
- Shared across all tabs/windows of same origin
- Use for: user preferences, cached data, long-term UI state
Both are scoped identically by Rsx_Storage (cookie, user, site, build).
MIGRATION FROM NATIVE STORAGE
If you're currently using sessionStorage/localStorage directly:
Before:
sessionStorage.setItem('my_key', JSON.stringify(data));
const data = JSON.parse(sessionStorage.getItem('my_key'));
sessionStorage.removeItem('my_key');
After:
Rsx_Storage.session_set('my_key', data);
const data = Rsx_Storage.session_get('my_key');
Rsx_Storage.session_remove('my_key');
Benefits:
- No manual JSON.stringify/parse
- Automatic scoping (prevents data leakage)
- Graceful handling of unavailable storage
- Automatic quota management
- Scope validation
DEBUGGING
Inspect storage in browser developer tools:
1. Open Developer Tools (F12)
2. Navigate to Application tab (Chrome) or Storage tab (Firefox)
3. Expand Session Storage or Local Storage
4. Look for keys matching: rsx::your_key::scope_suffix
The _rsx_scope_key shows the current active scope.
All RSpade keys are prefixed with `rsx::` for easy identification.
Console logging:
- Scope changes: "[Rsx_Storage] Scope changed, clearing RSpade keys only"
- First use: "[Rsx_Storage] Initializing scope (first use)"
- Quota exceeded: "[Rsx_Storage] Quota exceeded, clearing RSpade keys"
- Keys cleared: "[Rsx_Storage] Cleared X RSpade keys"
- Errors: "[Rsx_Storage] Failed to..."
FILE LOCATION
/system/app/RSpade/Core/Js/Rsx_Storage.js
SEE ALSO
Flash Alert system (uses Rsx_Storage for queue persistence)
php artisan rsx:man flash_alert
RSPADE January 2025 RSX_STORAGE(1)

View File

@@ -1,212 +1,663 @@
RSX TASK SYSTEM
================
The RSX Task system provides a structured way to define and execute background
tasks, similar to how Controllers handle HTTP requests but for CLI/background
execution.
NAME
Tasks - Unified background task execution with scheduling and queueing
## OVERVIEW
Tasks are static methods in Service classes that can be executed from:
- Command line (via `rsx:task:run`)
- Internal PHP code (via `Task::internal()`)
- Future: Queue systems, cron scheduling, progress tracking
## CREATING SERVICES
Services are classes that extend `Rsx_Service_Abstract` and live in:
/rsx/services/
Example service structure:
<?php
use App\RSpade\Core\Service\Rsx_Service_Abstract;
class My_Service extends Rsx_Service_Abstract
SYNOPSIS
Immediate CLI Execution
-----------------------
#[Task('Generate report')]
public static function generate_report(Task_Instance $task, array $params = [])
{
#[Task('Description of what this task does')]
public static function my_task(array $params = [])
return ['status' => 'complete'];
}
Run via: php artisan rsx:task:run Service_Name generate_report --param=value
Scheduled Tasks (Recurring)
---------------------------
#[Task('Clean thumbnails daily')]
#[Schedule('0 3 * * *')] // 3am daily, cron syntax
public static function clean_thumbnails(Task_Instance $task, array $params = [])
{
$task->log("Starting cleanup...");
// ... cleanup logic
}
Queued Tasks (Async from Application)
--------------------------------------
#[Task('Transcode video', queue: 'video', timeout: 3600)]
public static function transcode(Task_Instance $task, array $params = [])
{
$task->set_status('progress', 0);
$temp_dir = $task->get_temp_directory();
// ... transcoding logic
$task->set_status('progress', 100);
return ['output_path' => '/storage/tasks/123/output.mp4'];
}
// Dispatch from controller:
$task_id = Task::dispatch('Video_Service', 'transcode', ['video_id' => 123]);
// Poll for status:
$status = Task::status($task_id);
DESCRIPTION
RSX provides a unified task execution system with three execution modes:
1. Immediate CLI - Run tasks directly via artisan command
2. Scheduled - Recurring tasks run automatically on cron schedule
3. Queued - Async tasks dispatched from application code with status tracking
Philosophy: Visual Basic simplicity - define tasks with attributes, the
framework handles workers, scheduling, queueing, and cleanup automatically.
All tasks are Service methods with #[Task] attribute. Execution mode is
determined by additional attributes (#[Schedule]) or invocation method
(Task::dispatch()).
TASK EXECUTION MODES
Mode 1: Immediate CLI (Interactive)
------------------------------------
Run task immediately from command line. No database tracking, no timeout,
output printed to console.
Usage:
php artisan rsx:task:run Service_Name task_name --param=value
Characteristics:
- Synchronous execution
- No timeout enforcement
- No database tracking
- Output to STDOUT
- $task parameter provided but minimal functionality
Mode 2: Scheduled (Recurring)
------------------------------
Tasks run automatically on a cron schedule. Tracked in database, debounced
(no parallel execution), run as soon as possible after scheduled time.
Define with #[Schedule] attribute using cron syntax:
#[Schedule('0 3 * * *')] // Daily at 3am
#[Schedule('*/15 * * * *')] // Every 15 minutes
#[Schedule('0 */6 * * *')] // Every 6 hours
#[Schedule('0 2 * * 1')] // Mondays at 2am
Characteristics:
- Automatic execution when scheduled time reached
- Database tracking (next_run_at, started_at, completed_at)
- Debounced - if already running, skip until next schedule
- If execution missed (server down), runs as soon as possible
- Default queue: 'scheduled' with max_workers: 1
- Optional custom queue via queue parameter in #[Task]
Mode 3: Queued (Async Application)
-----------------------------------
Tasks dispatched from application code, run asynchronously with full
status tracking and progress updates.
Dispatch from code:
$task_id = Task::dispatch('Service_Name', 'task_name', $params);
Poll for status:
$status = Task::status($task_id);
// Returns: status, progress, log, result
Characteristics:
- Asynchronous execution
- Full database tracking
- Progress and status updates
- Timeout enforcement (default 30 minutes)
- Temporary file support with auto-cleanup
- Queue management with configurable concurrency
CREATING TASKS
Task Services
-------------
Tasks are static methods in Service classes extending Rsx_Service_Abstract.
Location: /rsx/services/
Basic structure:
<?php
namespace Rsx\Services;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
use App\RSpade\Core\Task\Task_Instance;
class My_Service extends Rsx_Service_Abstract
{
// Task implementation
#[Task('Description of task')]
public static function my_task(Task_Instance $task, array $params = [])
{
// Task implementation
return $result;
}
}
Task Method Requirements
------------------------
- Must be public static methods
- Must have #[Task('description')] attribute
- Must accept (Task_Instance $task, array $params = [])
- Should return data (stored as JSON in database for queued/scheduled)
Task Attribute
--------------
#[Task('Description', queue: 'name', timeout: seconds)]
Parameters:
- description (required) - Human-readable task description
- queue (optional) - Queue name for async execution (default: 'default')
- timeout (optional) - Timeout in seconds (default: 1800 = 30 min)
Queue must be defined in config/rsx.php or error thrown.
Timeout only enforced for queued/scheduled tasks, not immediate CLI.
Schedule Attribute
------------------
#[Schedule('cron_expression')]
Cron syntax (5 fields):
* * * * *
| | | | |
| | | | +-- Day of week (0-7, 0=Sunday, 7=Sunday)
| | | +---- Month (1-12)
| | +------ Day of month (1-31)
| +-------- Hour (0-23)
+---------- Minute (0-59)
Examples:
'0 3 * * *' - Daily at 3am
'*/15 * * * *' - Every 15 minutes
'0 */6 * * *' - Every 6 hours
'0 9 * * 1-5' - Weekdays at 9am
'30 2 1 * *' - First day of month at 2:30am
Tasks with #[Schedule] are automatically registered and run by task processor.
TASK INSTANCE API
The Task_Instance object provides methods for interacting with the task
execution context. Available in all execution modes.
Logging
-------
$task->log($message)
Appends text to task status log. Each call adds timestamped entry.
Stored in database for queued/scheduled tasks.
Example:
$task->log("Starting video processing...");
$task->log("Frame 100/500 processed");
Status Updates (Key-Value Pairs)
---------------------------------
$task->set_status($key, $value)
Sets arbitrary status data. Stored as JSON in database.
Use for progress tracking, time estimates, current step, etc.
Example:
$task->set_status('progress', 25);
$task->set_status('time_remaining', 120);
$task->set_status('current_step', 'encoding');
$task->set_status('frames_processed', 100);
Client can read via Task::status($task_id)->get('progress')
Temporary Directory
-------------------
$temp_dir = $task->get_temp_directory()
Returns: storage/tasks/{task_id}/
Creates temporary directory for task file operations. Automatically
cleaned up 1 hour after task completion (configurable).
Example:
$temp = $task->get_temp_directory();
$output = "{$temp}/output.mp4";
FFmpeg::transcode($input, $output);
Set Custom Cleanup Time
-----------------------
$task->set_temp_expiration($seconds)
Override default 1 hour cleanup. Call after get_temp_directory().
Example:
$temp = $task->get_temp_directory();
$task->set_temp_expiration(7200); // Keep for 2 hours
DISPATCHING TASKS
Dispatch from Application Code
-------------------------------
$task_id = Task::dispatch($service, $task, $params);
Parameters:
- $service - Service class name (e.g., 'Video_Service')
- $task - Task method name (e.g., 'transcode')
- $params - Associative array of parameters
Returns: Task ID (integer) for status polling
Example:
$task_id = Task::dispatch('Video_Service', 'transcode', [
'video_id' => 123,
'quality' => 'high'
]);
Auto-spawns worker if needed based on queue concurrency settings.
Check Task Status
-----------------
$status = Task::status($task_id);
Returns Task_Status object with:
- $status->status - 'pending', 'running', 'completed', 'failed', 'stuck'
- $status->get($key, $default) - Get status value
- $status->log - Full text log
- $status->result - Task return value (when completed)
- $status->started_at - Timestamp
- $status->completed_at - Timestamp
Example:
$status = Task::status($task_id);
if ($status->status === 'completed') {
$output = $status->result['output_path'];
} else {
$progress = $status->get('progress', 0);
}
QUEUE CONFIGURATION
Define queues in config/rsx.php:
'tasks' => [
'global_max_workers' => 1,
'default_timeout' => 1800, // 30 minutes
'cleanup_stuck_after' => 1800,
'temp_directory_default_ttl' => 3600, // 1 hour
'queues' => [
'default' => ['max_workers' => 1],
'scheduled' => ['max_workers' => 1],
'video' => ['max_workers' => 2],
'export' => ['max_workers' => 1],
'email' => ['max_workers' => 5],
],
],
Queue Settings
--------------
global_max_workers - Total concurrent workers across ALL queues
default_timeout - Default task timeout in seconds
cleanup_stuck_after - Consider task stuck after this many seconds
temp_directory_default_ttl - Auto-delete temp files after this long
Per-Queue Settings
------------------
max_workers - Maximum concurrent workers for this queue
Worker spawning logic:
1. When task dispatched, check running workers for that queue
2. If running < max_workers AND global limit not reached, spawn worker
3. Worker processes queue FIFO until empty, then exits
Undefined Queue Error
---------------------
Using queue name not defined in config throws error at dispatch time.
TASK PROCESSOR COMMAND
Start Task Processor
--------------------
php artisan rsx:task:process [--queue=name]
This command:
1. Cleans up stuck tasks (check PIDs, mark as stuck)
2. Processes scheduled tasks that are due
3. Checks all queues for pending tasks
4. Spawns workers up to max_workers per queue
5. Becomes a worker itself if under quota
6. Processes tasks until queue empty, then exits
Options:
--queue=name Process only specific queue
Running via Cron
----------------
Add to system crontab to run every minute:
* * * * * cd /var/www/html && php artisan rsx:task:process
This single cron entry handles:
- All scheduled tasks
- All queued tasks
- Worker management
- Stuck task cleanup
TASK DATABASE SCHEMA
Table: _tasks
-------------
- id - Task instance ID
- server_id - Server identifier (default 1, for future distributed tasks)
- service_name - Service class name
- task_name - Task method name
- queue_name - Queue name (NULL for scheduled tasks)
- execution_mode - 'scheduled' or 'queued'
- status - 'pending', 'running', 'completed', 'failed', 'stuck'
- params - JSON parameters
- result - JSON task return value
- status_data - JSON status key-value pairs
- status_log - Text log (append-only)
- scheduled_at - When task should run (scheduled tasks)
- next_run_at - Next execution time (recurring scheduled)
- started_at - When task started processing
- completed_at - When task finished
- timeout_seconds - Task timeout
- worker_pid - Process ID of worker
- has_temp_directory - Boolean flag
- temp_expires_at - When to cleanup temp files
- created_at, updated_at
Advisory Locking
----------------
Database advisory locks coordinate queue access:
- Lock key: task_queue_{queue_name}
- Prevents race conditions in worker spawning
- Atomic task selection and status updates
TASK LIFECYCLE
Scheduled Task Lifecycle
------------------------
1. Task registered with #[Schedule] attribute
2. Entry created in _tasks with next_run_at
3. Task processor checks scheduled tasks every minute
4. When next_run_at <= now, task status set to 'pending'
5. Worker picks up task, sets status to 'running', records worker_pid
6. Task executes, updates logged and status tracked
7. On completion, status set to 'completed', result stored
8. next_run_at calculated for next execution based on cron
Queued Task Lifecycle
---------------------
1. Task::dispatch() called from application code
2. Entry created in _tasks with status 'pending'
3. Worker spawned if needed based on concurrency settings
4. Worker picks up task atomically via advisory lock
5. Status set to 'running', worker_pid recorded
6. Task executes with progress updates via $task API
7. On completion, status set to 'completed', result stored
8. Temp directory marked for cleanup if created
Stuck Task Detection
--------------------
Task considered stuck if:
- Status is 'running'
- started_at + timeout_seconds < now
Task processor handles stuck tasks:
1. Check if worker_pid still running
2. If PID dead, mark task 'stuck'
3. If PID alive but timeout exceeded, kill PID and mark 'stuck'
4. Log stuck task for manual review
EXAMPLES
Example 1: Scheduled Cleanup Task
----------------------------------
<?php
namespace Rsx\Services;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
use App\RSpade\Core\Task\Task_Instance;
class Cleanup_Service extends Rsx_Service_Abstract
{
#[Task('Clean old thumbnails daily at 3am')]
#[Schedule('0 3 * * *')]
public static function clean_thumbnails(Task_Instance $task, array $params = [])
{
$task->log("Starting thumbnail cleanup");
$deleted = 0;
// ... cleanup logic
$task->log("Cleanup complete. Deleted {$deleted} files.");
return ['deleted_count' => $deleted];
}
}
Example 2: Video Transcoding (Queued)
--------------------------------------
<?php
namespace Rsx\Services;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
use App\RSpade\Core\Task\Task_Instance;
class Video_Service extends Rsx_Service_Abstract
{
#[Task('Transcode uploaded video', queue: 'video', timeout: 7200)]
public static function transcode(Task_Instance $task, array $params = [])
{
$video_id = $params['video_id'];
$video = Video_Model::find($video_id);
$task->log("Loading video {$video_id}");
$task->set_status('progress', 0);
$temp = $task->get_temp_directory();
$task->set_temp_expiration(7200); // Keep for 2 hours
$input = $video->get_storage_path();
$output = "{$temp}/output.mp4";
FFmpeg::transcode($input, $output, function($progress) use ($task) {
$task->set_status('progress', $progress);
$task->log("Progress: {$progress}%");
});
$task->set_status('progress', 100);
$task->log("Transcoding complete");
$attachment = File_Attachment_Model::create_from_path($output);
return [
'message' => 'Task completed',
'data' => 'result data'
'attachment_id' => $attachment->id,
'output_path' => $output,
];
}
}
## TASK METHODS
// Controller dispatch:
#[Ajax_Endpoint]
public static function upload_video(Request $request, array $params = [])
{
$video = Video_Model::create([...]);
Tasks must:
- Be public static methods
- Have the #[Task('description')] attribute
- Accept array $params = [] parameter
- Return data (will be JSON-encoded for CLI output)
$task_id = Task::dispatch('Video_Service', 'transcode', [
'video_id' => $video->id
]);
Task signature:
public static function task_name(array $params = [])
## PARAMETER HANDLING
Services inherit parameter helpers from Rsx_Service_Abstract:
protected static function __param($params, $key, $default = null)
protected static function __has_param($params, $key)
Example usage:
$name = static::__param($params, 'name', 'Guest');
if (static::__has_param($params, 'force')) {
// ...
return ['video_id' => $video->id, 'task_id' => $task_id];
}
## PRE-TASK HOOK
Override pre_task() to run validation/auth before any task executes:
public static function pre_task(array $params = [])
// Status polling:
#[Ajax_Endpoint]
public static function check_transcode(Request $request, array $params = [])
{
if (!RsxAuth::check()) {
throw new \Exception("Authentication required");
}
return null; // Continue to task
}
If pre_task() returns non-null, task execution halts and returns that value.
## LISTING TASKS
View all available tasks:
php artisan rsx:task:list
Output shows services and their tasks with descriptions:
Service_Test
hello_world - Test task with no arguments
greet - Test task with optional name parameter
calculate - Test task with multiple parameters
## RUNNING TASKS
Execute a task from command line:
php artisan rsx:task:run Service task_name
With parameters:
php artisan rsx:task:run Service task_name --param=value
php artisan rsx:task:run Service greet --name=John
Boolean flags:
php artisan rsx:task:run Service task_name --force
JSON values (auto-parsed):
php artisan rsx:task:run Service task_name --data='{"key":"value"}'
Debug mode (wrapped response):
php artisan rsx:task:run Service task_name --debug
## OUTPUT MODES
Default mode - Raw JSON response (just the return value):
{
"message": "Task completed",
"count": 42
}
Debug mode - Wrapped response with success indicator:
{
"success": true,
"result": {
"message": "Task completed",
"count": 42
}
}
## INTERNAL TASK CALLS
Call tasks from PHP code using Task::internal():
use App\RSpade\Core\Task\Task;
$result = Task::internal('Service_Name', 'task_name', [
'param1' => 'value1',
'param2' => 'value2'
]);
This is useful for:
- Composing complex tasks from simpler ones
- Calling tasks from controllers
- Background job processing
Example composition:
#[Task('Run all seeders')]
public static function seed_all(array $params = [])
{
$clients = Task::internal('Seeder_Service', 'seed_clients', $params);
$contacts = Task::internal('Seeder_Service', 'seed_contacts', $params);
$status = Task::status($params['task_id']);
return [
'clients' => $clients,
'contacts' => $contacts
'status' => $status->status,
'progress' => $status->get('progress', 0),
'log' => $status->log,
'result' => $status->result,
];
}
## ERROR HANDLING
All errors return JSON (never throws to stderr):
Example 3: Export Generation (Queued)
--------------------------------------
#[Task('Generate CSV export', queue: 'export', timeout: 1800)]
public static function generate_export(Task_Instance $task, array $params = [])
{
"success": false,
"error": "Error message",
"error_type": "Exception",
"trace": "..."
$task->log("Starting export generation");
$temp = $task->get_temp_directory();
$csv_path = "{$temp}/export.csv";
$total = Client_Model::count();
$processed = 0;
$fp = fopen($csv_path, 'w');
fputcsv($fp, ['ID', 'Name', 'Email', 'Created']);
Client_Model::chunk(100, function($clients) use ($fp, &$processed, $total, $task) {
foreach ($clients as $client) {
fputcsv($fp, [
$client->id,
$client->name,
$client->email,
$client->created_at,
]);
$processed++;
}
$progress = round(($processed / $total) * 100);
$task->set_status('progress', $progress);
$task->log("Processed {$processed}/{$total} clients");
});
fclose($fp);
$attachment = File_Attachment_Model::create_from_path($csv_path);
return ['attachment_id' => $attachment->id];
}
Exit codes:
- 0: Success
- 1: Error
BUILT-IN SCHEDULED TASKS
## ATTRIBUTE CONFLICTS
The framework includes built-in scheduled tasks in app/RSpade/Core/Tasks/:
A method cannot have multiple execution type attributes. These conflict:
- #[Route] (HTTP routes)
- #[Ajax_Endpoint] (Ajax endpoints)
- #[Task] (CLI tasks)
Cleanup_Service::cleanup_old_tasks
-----------------------------------
Removes completed/failed tasks older than configured retention period.
Runs daily at 2am.
The manifest build will fail if these are mixed on the same method.
Cleanup_Service::cleanup_temp_directories
------------------------------------------
Deletes temporary task directories past their expiration time.
Runs hourly.
## USE CASES
These tasks are automatically registered and require no user configuration.
Tasks are ideal for:
- Database seeders
- Data migrations
- Report generation
- Batch processing
- Maintenance operations
- Background jobs
- Scheduled operations
TROUBLESHOOTING
Example services:
Seeder_Service - Database seeding
Report_Service - Generate reports
Cleanup_Service - Maintenance tasks
Import_Service - Data imports
Export_Service - Data exports
Task Not Running
----------------
Problem: Scheduled task not executing
## FUTURE FEATURES (NOT YET IMPLEMENTED)
Checks:
- Is cron configured? (* * * * * php artisan rsx:task:process)
- Check _tasks table for next_run_at timestamp
- Run manually: php artisan rsx:task:process
- Check Laravel logs for errors
The Task system is designed to support future enhancements:
- Queue integration (dispatch tasks to Redis/database queue)
- Cron scheduling (#[Schedule] attribute)
- Progress tracking (long-running tasks report progress)
- Task history (log of all task executions)
- Task dependencies (ensure X runs before Y)
- Parallel execution (run multiple tasks concurrently)
Worker Not Spawning
--------------------
Problem: Dispatched task stays 'pending'
## EXAMPLES
Checks:
- Is global_max_workers reached?
- Is queue max_workers reached?
- Is task processor running? (cron or manual)
- Check _tasks table for worker_pid
- Try: php artisan rsx:task:process --queue=queuename
See example services:
/rsx/services/service_test.php - Basic task examples
/rsx/services/seeder_service.php - Database seeding examples
Task Stuck
----------
Problem: Task shows 'stuck' status
Test tasks:
php artisan rsx:task:list
php artisan rsx:task:run Service_Test hello_world
php artisan rsx:task:run Service_Test greet --name=Brian
php artisan rsx:task:run Service_Test calculate --a=10 --b=5 --op=multiply
Causes:
- Worker process killed/crashed
- Timeout exceeded
- Server restarted
Recovery:
- Task processor automatically detects stuck tasks
- Stuck tasks marked for manual review
- Check status_log for clues
- Re-dispatch if needed
Undefined Queue Error
---------------------
Problem: Error when dispatching task
Solution:
Define queue in config/rsx.php:
'queues' => [
'myqueue' => ['max_workers' => 1],
],
BEST PRACTICES
1. Choose Right Execution Mode
- Immediate CLI: Development, testing, one-off operations
- Scheduled: Recurring maintenance, cleanup, reports
- Queued: User-triggered async work, long-running processes
2. Set Appropriate Timeouts
- Default 30 minutes is generous
- Video transcoding: 2+ hours
- Exports: 30 minutes
- Email sending: 5 minutes
3. Use Descriptive Logging
- Log major steps, not every iteration
- Include useful context (IDs, counts, progress)
- Helps debugging stuck/failed tasks
4. Queue Naming
- Use semantic names: 'video', 'export', 'email'
- Group similar workloads
- Set concurrency based on resource usage
5. Temp Directory Cleanup
- Only create if needed
- Set expiration based on use case
- Return attachment_id instead of path when possible
6. Status Updates
- Update progress regularly (every 5-10%)
- Include time estimates when possible
- Use consistent key names across tasks
7. Error Handling
- Throw exceptions for failures
- Task status automatically set to 'failed'
- Exception message stored in result
- Don't catch and swallow errors
FUTURE FEATURES (NOT YET IMPLEMENTED)
The task system is designed to support future enhancements:
- Distributed tasks across multiple servers (server_id support)
- Metrics and monitoring (task duration, failure rates, queue depth)
SEE ALSO
service.txt - Service class documentation
ajax.txt - Ajax endpoint system
config_rsx.txt - RSX configuration reference

504
app/RSpade/man/thumbnails.txt Executable file
View File

@@ -0,0 +1,504 @@
THUMBNAILS
==========
NAME
Thumbnails - Two-tier cached thumbnail system with named presets and dynamic sizes
SYNOPSIS
Named Preset Thumbnails (Recommended)
-------------------------------------
// Define presets in config/rsx.php
'thumbnails' => [
'presets' => [
'profile' => ['type' => 'cover', 'width' => 200, 'height' => 200],
'gallery' => ['type' => 'fit', 'width' => 400, 'height' => 300],
],
],
// Use in templates
<img src="<?= $attachment->get_thumbnail_url_preset('profile') ?>" />
Dynamic Thumbnails (Ad-hoc Sizes)
----------------------------------
<img src="<?= $attachment->get_thumbnail_url('cover', 200, 200) ?>" />
<img src="<?= $attachment->get_thumbnail_url('fit', 400) ?>" />
Artisan Commands
----------------
php artisan rsx:thumbnails:stats
php artisan rsx:thumbnails:clean [--preset] [--dynamic]
php artisan rsx:thumbnails:generate [--preset=profile]
DESCRIPTION
RSX provides a two-tier thumbnail caching system designed to prevent cache
pollution from arbitrary thumbnail sizes while maintaining flexibility for
developers.
IMPORTANT: Thumbnails automatically scale to 2x requested dimensions for
HiDPI/Retina displays, but cap at 66% of source image dimensions to avoid
excessive upscaling. The output aspect ratio always matches requested
dimensions, but actual resolution may be lower than requested.
Unlike traditional approaches that generate thumbnails on-demand without
limits, RSX distinguishes between:
1. Preset Thumbnails - Developer-defined named sizes for application use
- Managed via scheduled cleanup tasks
- Protected from spam/abuse
- Optimized for cache warming
2. Dynamic Thumbnails - Ad-hoc sizes requested via URL parameters
- Synchronous quota enforcement after each generation
- LRU eviction (oldest files deleted first)
- For edge cases and development
Philosophy: Define thumbnail sizes once in config, reference by name
throughout your application. This prevents users from generating thousands
of arbitrary-sized thumbnails and polluting your cache.
Storage Location: storage/rsx-thumbnails/preset/ and .../dynamic/
These directories survive rsx:clean and are managed separately via
rsx:thumbnails:clean command (schedule this for periodic cleanup).
PRESET THUMBNAILS
Configuration
-------------
Define named presets in /system/config/rsx.php:
'thumbnails' => [
'presets' => [
'profile' => ['type' => 'cover', 'width' => 200, 'height' => 200],
'gallery' => ['type' => 'fit', 'width' => 400, 'height' => 300],
'icon_small' => ['type' => 'cover', 'width' => 32, 'height' => 32],
'icon_large' => ['type' => 'cover', 'width' => 64, 'height' => 64],
],
'quotas' => [
'preset_max_bytes' => 100 * 1024 * 1024, // 100MB
'dynamic_max_bytes' => 50 * 1024 * 1024, // 50MB
],
'touch_on_read' => true,
'touch_interval' => 600, // 10 minutes
],
Thumbnail Types
---------------
cover - Fills dimensions completely, cropping excess (like background-size: cover)
fit - Maintains aspect ratio within dimensions, transparent background
Usage in Code
-------------
// Get preset thumbnail URL
$url = $attachment->get_thumbnail_url_preset('profile');
// Use in Blade templates
<img src="<?= $user->get_attachment('profile_photo')->get_thumbnail_url_preset('profile') ?>" />
// Use in jqhtml templates
<img src="<%= attachment.get_thumbnail_url_preset('gallery') %>" />
Quota Management
----------------
Preset thumbnails are enforced via scheduled task (not synchronous):
Schedule this command to run periodically (e.g., daily):
php artisan rsx:thumbnails:clean --preset
This deletes oldest preset thumbnails until under quota limit.
Cache Warming
-------------
Pre-generate preset thumbnails for all attachments:
php artisan rsx:thumbnails:generate
php artisan rsx:thumbnails:generate --preset=profile
php artisan rsx:thumbnails:generate --key=abc123def456
Useful after deployment or when adding new presets.
DYNAMIC THUMBNAILS
Usage
-----
For edge cases where preset sizes don't fit:
$url = $attachment->get_thumbnail_url('cover', 200, 200);
$url = $attachment->get_thumbnail_url('fit', 400); // Height optional
<img src="<?= $attachment->get_thumbnail_url('cover', 150, 150) ?>" />
Quota Enforcement
-----------------
Dynamic thumbnails are enforced SYNCHRONOUSLY after each new thumbnail:
- Check total directory size
- If over quota, delete oldest files (by mtime) until under limit
- This happens automatically, no scheduled task needed
When to Use
-----------
- Development/testing with non-standard sizes
- Edge cases where preset sizes don't fit
- One-off thumbnail requirements
When NOT to Use
---------------
- Regular application thumbnails (use presets instead)
- User-facing features (prevents cache pollution)
CACHING BEHAVIOR
Cache Storage
-------------
Preset: storage/rsx-thumbnails/preset/{preset_name}_{hash}_{ext}.webp
Dynamic: storage/rsx-thumbnails/dynamic/{type}_{w}x{h}_{hash}_{ext}.webp
All thumbnails are WebP format for optimal size/quality balance.
Cache Hits
----------
When thumbnail exists:
1. Attempt to open file (handles race condition if deleted)
2. Touch mtime if enabled and >10 minutes old (LRU tracking)
3. Serve from disk with 1-year cache headers
Cache Misses
------------
When thumbnail doesn't exist:
1. Generate via Imagick (images) or icon-based (non-images)
2. Save to cache directory
3. Enforce quota (dynamic only)
4. Serve from memory (don't re-read from disk)
LRU Eviction
------------
Both preset and dynamic thumbnails use LRU (Least Recently Used):
- mtime touched on cache hit (configurable interval)
- Oldest files (by mtime) deleted first when over quota
- Prevents deleting frequently-accessed thumbnails
Race Condition Handling
-----------------------
Between file existence check and open, file might be deleted by quota
enforcement. The system handles this gracefully:
- fopen() failure triggers regeneration
- No error shown to user
- Thumbnail generated and served immediately
ARTISAN COMMANDS
rsx:thumbnails:stats
--------------------
Display usage statistics for both preset and dynamic caches.
Output includes:
- File count and total size
- Quota usage percentage
- Oldest and newest thumbnail ages
- Breakdown by preset name (for preset thumbnails)
Example:
$ php artisan rsx:thumbnails:stats
Preset Thumbnails:
Files: 1,234
Total Size: 45.2 MB / 100 MB (45.2%)
Oldest: 30 days ago
Newest: 2 minutes ago
Dynamic Thumbnails:
Files: 567
Total Size: 38.7 MB / 50 MB (77.4%)
Oldest: 7 days ago
Newest: 5 seconds ago
Breakdown by preset:
profile: 234 files, 12.3 MB
gallery: 567 files, 28.9 MB
rsx:thumbnails:clean
--------------------
Enforce quotas by deleting oldest files until under limit.
Options:
--preset Clean only preset thumbnails
--dynamic Clean only dynamic thumbnails
--all Clean both (default if no options specified)
Examples:
php artisan rsx:thumbnails:clean
php artisan rsx:thumbnails:clean --preset
php artisan rsx:thumbnails:clean --dynamic
Schedule this for preset thumbnails:
// In your scheduler (future feature)
$schedule->command('rsx:thumbnails:clean --preset')->daily();
rsx:thumbnails:generate
-----------------------
Pre-generate preset thumbnails for attachments.
Options:
--preset=name Generate specific preset for all attachments
--key=xyz Generate thumbnails for specific attachment
--all Generate all presets for all attachments (default)
Examples:
php artisan rsx:thumbnails:generate
php artisan rsx:thumbnails:generate --preset=profile
php artisan rsx:thumbnails:generate --key=abc123def456
Use cases:
- Cache warming after deployment
- Regenerating after changing preset dimensions
- Background generation for new uploads
CONFIGURATION OPTIONS
thumbnails.presets
------------------
Array of named presets with type, width, and height.
Format: ['name' => ['type' => 'cover'|'fit', 'width' => int, 'height' => int]]
Example:
'presets' => [
'profile' => ['type' => 'cover', 'width' => 200, 'height' => 200],
'gallery' => ['type' => 'fit', 'width' => 400, 'height' => 300],
],
thumbnails.quotas.preset_max_bytes
----------------------------------
Maximum bytes for preset thumbnail directory.
Enforced via scheduled rsx:thumbnails:clean --preset.
Default: 100 * 1024 * 1024 (100MB)
thumbnails.quotas.dynamic_max_bytes
-----------------------------------
Maximum bytes for dynamic thumbnail directory.
Enforced synchronously after each new dynamic thumbnail.
Default: 50 * 1024 * 1024 (50MB)
thumbnails.max_dynamic_size
---------------------------
Maximum dimension limit for dynamic thumbnails (base resolution).
This value represents the pre-2x-scaling dimension limit. The actual
generated thumbnail will be double this value.
Example: 800 allows requests up to 800x800, generates 1600x1600
Preset thumbnails have no enforced maximum (developer-controlled).
Default: 800
NOTE: Application configuration - set in config/rsx.php, not .env
thumbnails.touch_on_read
------------------------
Whether to touch file mtime on cache hit (for LRU tracking).
Default: true
Environment: THUMBNAILS_TOUCH_ON_READ
thumbnails.touch_interval
-------------------------
Only touch mtime if file is older than this many seconds.
Prevents excessive filesystem writes while maintaining LRU accuracy.
Default: 600 (10 minutes)
Environment: THUMBNAILS_TOUCH_INTERVAL
DIMENSION LIMITS AND RESOLUTION SCALING
HiDPI/Retina Display Support
----------------------------
All thumbnails automatically scale to 2x requested dimensions for sharp
display on high-DPI screens. However, to prevent excessive upscaling:
- Requested dimensions are multiplied by 2x
- If 2x dimensions exceed 66% of source image size on either axis,
the source image dimensions are used instead
- Aspect ratio is preserved by cropping to requested proportions
Examples:
Request: 100x100 from 500x500 source
Generated: 200x200 (2x scaling applied)
Request: 200x200 from 500x500 source
66% threshold: 330x330 (500 * 0.66)
2x target: 400x400 (exceeds 330)
Generated: 500x500 (uses source, crops to 1:1 aspect ratio)
Request: 100x100 from 150x150 source
66% threshold: 99x99
2x target: 200x200 (exceeds 99)
Generated: 150x150 (uses source dimensions)
This ensures thumbnails are always sharp on HiDPI displays while avoiding
quality loss from excessive upscaling of small source images.
Dynamic Thumbnail Constraints
------------------------------
Dynamic thumbnails enforce additional dimension limits to prevent abuse:
- Minimum: 10x10 pixels (before 2x scaling)
- Maximum: Configurable via max_dynamic_size (default 800x800)
- After 2x scaling: becomes 1600x1600 with default setting
Preset thumbnails have no enforced limits (developer-controlled).
To change the dynamic maximum:
// In config/rsx.php
'thumbnails' => [
'max_dynamic_size' => 1200, // 1200 base → 2400 after 2x
]
Output Resolution Not Guaranteed
---------------------------------
The actual output resolution may differ from requested dimensions due to:
- 2x scaling for HiDPI displays
- 66% source dimension threshold
- Maximum dimension constraints (default 1600x1600 for dynamic)
However, the aspect ratio ALWAYS matches requested dimensions. Plan your
layouts accordingly - use CSS to constrain display size if needed.
NON-IMAGE FILES
Thumbnails for non-image files (PDFs, documents, etc.) use icon-based
rendering. The system generates a WebP thumbnail containing the
appropriate file type icon.
Icons are provided by File_Attachment_Icons class and include:
- Brand-specific icons (PDF, Photoshop, Illustrator)
- Generic category icons (document, video, audio, archive)
FILESYSTEM STRUCTURE
storage/rsx-thumbnails/
├── preset/
│ ├── profile_abc123_jpg.webp
│ ├── profile_def456_png.webp
│ └── gallery_abc123_jpg.webp
└── dynamic/
├── cover_200x200_abc123_jpg.webp
└── fit_400x300_def456_png.webp
Filename Format:
- Preset: {preset_name}_{storage_hash}_{extension}.webp
- Dynamic: {type}_{width}x{height}_{storage_hash}_{extension}.webp
Why include extension in filename?
Handles edge case where identical file content uploaded with different
extensions (e.g., zip vs docx) should render different icons.
BEST PRACTICES
1. Use Preset Thumbnails Everywhere
Define all thumbnail sizes your application needs as presets.
This prevents cache pollution and enables quota management.
2. Name Presets Semantically
Use names like 'profile', 'gallery', 'thumbnail' instead of dimensions.
This makes code more maintainable when you change sizes.
3. Schedule Preset Cleanup
Add rsx:thumbnails:clean --preset to your scheduler (daily/weekly).
This prevents preset cache from growing unbounded.
4. Monitor Cache Usage
Run rsx:thumbnails:stats periodically to monitor quota usage.
Adjust quotas if needed.
5. Warm Cache After Deployment
Run rsx:thumbnails:generate after deploying to pre-generate thumbnails.
This prevents first-visitor slowness.
6. Use Dynamic Sparingly
Reserve dynamic thumbnails for edge cases and development.
Production features should use presets.
TROUBLESHOOTING
Preset Not Found Error
----------------------
Error: "Thumbnail preset 'xyz' not defined"
Solution: Add preset to config/rsx.php:
'thumbnails' => [
'presets' => [
'xyz' => ['type' => 'cover', 'width' => 200, 'height' => 200],
],
],
Cache Growing Too Large
-----------------------
Problem: Thumbnail cache exceeds quota
Solutions:
- Lower quota in config/rsx.php
- Run rsx:thumbnails:clean manually
- Schedule rsx:thumbnails:clean for automatic cleanup
- Reduce number of presets
- Decrease touch_interval to improve LRU accuracy
Thumbnails Not Generating
--------------------------
Problem: Blank images or 404 errors
Checks:
- ImageMagick installed? (php -m | grep imagick)
- File exists on disk? (check attachment->file_storage->get_full_path())
- Directory writable? (storage/rsx-thumbnails/ permissions)
- Check Laravel logs for exceptions
Slow Initial Load
-----------------
Problem: First thumbnail request is slow
Solution: Pre-generate thumbnails
php artisan rsx:thumbnails:generate
Thumbnails Deleted Unexpectedly
--------------------------------
Problem: Dynamic thumbnails disappear
Explanation: Dynamic quota enforcement deletes oldest files when over limit.
This is intentional to prevent abuse.
Solutions:
- Convert to preset thumbnail if regularly needed
- Increase dynamic_max_bytes quota
- Reduce dynamic thumbnail usage
FUTURE ENHANCEMENTS
Client-Side DPR Detection
-------------------------
Current limitation: Server always generates at 2x resolution regardless of
actual client display capabilities.
Planned enhancement: JavaScript helpers that detect window.devicePixelRatio
and automatically calculate display-appropriate dimensions.
Proposed API:
// JavaScript - automatically adjusts for devicePixelRatio
const url = attachment.get_thumbnail_url_for_display('cover', 200, 200);
// On 1x display: requests 200x200 (server generates 400x400)
// On 2x display: requests 200x200 (server generates 400x400)
// On 3x display: requests 300x300 (server generates 600x600)
Benefits:
- Optimized bandwidth usage per device
- Better cache utilization
- Sharper images on 3x displays
- Smaller images for 1x displays
This would require client-side JavaScript helpers integrated with the
File_Attachment_Model fetch system.
SEE ALSO
file_upload.txt - File attachment and upload system
storage.txt - Storage directory organization
config_rsx.txt - RSX configuration reference