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

@@ -39,7 +39,7 @@ The user (framework creator) observed the implementation and provided corrective
**JavaScript:**
```javascript
class Contacts_DataGrid extends Jqhtml_Component {
class Contacts_DataGrid extends Component {
async on_load() {
// ❌ WRONG: Setting loading state at START
this.data.state = {loading: true};
@@ -76,7 +76,7 @@ class Contacts_DataGrid extends Jqhtml_Component {
**JavaScript:**
```javascript
class Contacts_DataGrid extends Jqhtml_Component {
class Contacts_DataGrid extends Component {
async on_load() {
// ✅ CORRECT: NO loading flags at start
// ✅ CORRECT: NO manual this.render() calls

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Models;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
/**
* _AUTO_GENERATED_ Database type hints - do not edit manually
* Generated on: 2025-09-28 10:36:08
* Table: flash_alerts
*
* @property int $id
* @property mixed $session_id
* @property string $message
* @property mixed $class_attribute
* @property string $created_at
* @property int $created_by
* @property int $updated_by
* @property string $updated_at
*
* @mixin \Eloquent
*/
class FlashAlert extends Rsx_Model_Abstract
{
// Required static properties from parent abstract class
public static $enums = [];
public static $rel = [];
protected $table = 'flash_alerts';
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}

View File

@@ -64,7 +64,7 @@ The Code Quality system is a modular, extensible framework for enforcing coding
1. **JqhtmlInlineScriptRule** (JQHTML-INLINE-01)
- Prohibits inline <script> and <style> tags in .jqhtml templates
- Enforces component class pattern with Jqhtml_Component
- Enforces component class pattern with Component
- Requires separate .js and .scss files
- Severity: Critical
- Runs at manifest-time
@@ -187,15 +187,171 @@ php artisan rsx:check rsx/
php artisan rsx:check app/Models/User.php
```
### Exception Comments
### Exception Granting System
Add exception comments to bypass specific violations:
The code quality system supports granting exceptions to allow specific violations when justified. Exceptions are granted via specially formatted comments in the source files.
#### Exception Comment Format
```
@{RULE-ID}-EXCEPTION - Optional rationale
```
**Naming Convention:**
- Use the exact rule ID from `get_id()` method
- Add `-EXCEPTION` suffix
- Examples: `@PHP-NAMING-01-EXCEPTION`, `@JS-DEFENSIVE-01-EXCEPTION`, `@FILE-CASE-01-EXCEPTION`
#### Exception Placement
**Line-Level Exceptions** (most common):
Place the exception comment on the same line as the violation OR on the line immediately before it.
```php
// @RULE-ID-EXCEPTION (e.g., @PHP-NAMING-01-EXCEPTION)
// Code that would normally violate rules
// Same-line exception
if (key && key.startsWith('rsx::')) { // @JS-DEFENSIVE-01-EXCEPTION - storage.key() can return null
// Previous-line exception
// @PHP-REFLECT-01-EXCEPTION - Test runner needs ReflectionClass for filtering
if ($reflection->isAbstract()) {
continue;
}
```
**File-Level Exceptions** (for entire file):
Place at the top of the file, after namespace/use statements, before the main docblock.
```php
<?php
namespace App\RSpade\Core\Ajax;
use SomeClass;
// @FILE-SUBCLASS-01-EXCEPTION
/**
* Class docblock
*/
class MyClass {
```
**Docblock Exceptions** (for method/class):
Place inside the docblock using JSDoc/PHPDoc style.
```php
/**
* Check if a method is overriding a parent method
*
* @PHP-REFLECT-02-EXCEPTION: This method needs ReflectionClass to check parent methods
* from Laravel framework classes which are not tracked in the manifest.
*/
protected function is_overriding_parent_method($class_name, $method_name) {
```
#### Implementing Exception Handling in Rules
**NOT all rules implement exception handling** - it must be added per-rule. Approximately 31 of 111 rules currently support exceptions.
**To check if a rule supports exceptions:**
1. Open the rule file in `/system/app/RSpade/CodeQuality/Rules/`
2. Search for `EXCEPTION` in the file
3. If found, the rule implements exception checking
4. If not found, exceptions will be ignored by that rule
**To implement exception handling in a rule:**
Add a check before calling `add_violation()`. The exact implementation depends on rule structure:
**Line-by-line checking pattern:**
```php
public function check(string $file_path, string $contents, array $metadata = []): void
{
$lines = explode("\n", $contents);
foreach ($lines as $line_number => $line) {
$actual_line_number = $line_number + 1;
// Skip if line has exception comment
if (str_contains($line, '@' . $this->get_id() . '-EXCEPTION')) {
continue;
}
// Check for violation
if ($this->detect_violation($line)) {
$this->add_violation(...);
}
}
}
```
**Multi-line or previous-line pattern:**
```php
// Check previous line for exception comment
$prev_line_index = $line_number - 1;
if ($prev_line_index >= 0 && str_contains($lines[$prev_line_index], '@' . $this->get_id() . '-EXCEPTION')) {
continue; // Skip this line
}
```
**File-level pattern:**
```php
public function check(string $file_path, string $contents, array $metadata = []): void
{
// Check if entire file has exception
if (str_contains($contents, '@' . $this->get_id() . '-EXCEPTION')) {
return; // Skip entire file
}
// ... rest of checking logic
}
```
#### Exception Rationale Guidelines
**Always include a rationale** explaining WHY the exception is needed:
```javascript
// @JS-DEFENSIVE-01-EXCEPTION - Browser API storage.key(i) can return null when i >= storage.length
```
**Good rationales:**
- Reference external API behavior: "Browser API returns null", "Laravel method signature requires this"
- Explain architectural necessity: "Task system uses direct queries for performance"
- Note optional/polymorphic patterns: "Array.find() returns undefined when no match"
**Bad rationales:**
- "TODO: fix later"
- "Not sure why this is needed"
- No rationale at all
#### CRITICAL: AI Agent Exception Policy
**ABSOLUTE PROHIBITION - NEVER GRANT EXCEPTIONS AUTONOMOUSLY**
When you encounter a code quality violation, you are **FORBIDDEN** from granting exceptions without explicit programmer approval.
**Required procedure:**
1. **STOP** - Do not add exception comments
2. **ANALYZE** - Determine if the violation is:
- Invalid defensive coding (should be removed)
- Valid duck typing (needs exception)
- External API constraint (needs exception)
3. **REPORT** - Present findings: "Found violation X in file Y. Analysis: [your reasoning]. Options: a) Remove the check (fail-loud), b) Grant exception (provide rationale), c) Refactor differently"
4. **WAIT** - Wait for programmer to decide
5. **NEVER** add `@RULE-ID-EXCEPTION` comments without explicit approval
**Only grant exceptions when:**
- Programmer explicitly requests it: "grant an exception for this"
- Programmer approves your recommendation: "yes, add the exception"
- You are implementing a fix that the programmer has already approved
**Exception grants are permanent code changes** - they suppress violations indefinitely and become part of the codebase. Do not make this decision autonomously.
## Development
### Creating New Rules

View File

@@ -297,7 +297,7 @@ Companion JavaScript ({$js_path}):
/**
* {$class_name} jqhtml component
*/
class {$class_name} extends Jqhtml_Component {
class {$class_name} extends Component {
on_create() {
// Initialize component state
this.data.count = 0;

View File

@@ -233,18 +233,18 @@ class HardcodedInternalUrl_CodeQualityRule extends CodeQualityRule_Abstract
if ($is_jqhtml) {
// JavaScript version for .jqhtml files using <%= %> syntax
if (empty($params)) {
return "<%= Rsx.Route('{$class_name}', '{$method_name}') %>";
return "<%= Rsx.Route('{$class_name}::{$method_name}') %>";
} else {
$params_json = json_encode($params, JSON_UNESCAPED_SLASHES);
return "<%= Rsx.Route('{$class_name}', '{$method_name}', {$params_json}) %>";
return "<%= Rsx.Route('{$class_name}::{$method_name}', {$params_json}) %>";
}
} else {
// PHP version for .blade.php files
if (empty($params)) {
return "{{ Rsx::Route('{$class_name}', '{$method_name}') }}";
return "{{ Rsx::Route('{$class_name}::{$method_name}') }}";
} else {
$params_str = $this->_format_php_array($params);
return "{{ Rsx::Route('{$class_name}', '{$method_name}', {$params_str}) }}";
return "{{ Rsx::Route('{$class_name}::{$method_name}', {$params_str}) }}";
}
}
}

View File

@@ -12,7 +12,7 @@ use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
*
* Example violations:
* - Rsx::Route('Controller') // PHP
* - Rsx.Route('Controller', 'index') // JavaScript
* - Rsx.Route('Controller::index') // JavaScript
*
* Correct usage:
* - Rsx::Route('Controller') // PHP

View File

@@ -86,16 +86,36 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
*/
private function route_exists(string $controller, string $method): bool
{
// First check if this is a SPA action (JavaScript class extending Spa_Action)
if ($this->is_spa_action($controller)) {
return true;
}
// Otherwise check PHP routes
try {
// Use the same validation logic as Rsx::Route()
// If this doesn't throw an exception, the route exists
\App\RSpade\Core\Rsx::Route($controller, $method);
\App\RSpade\Core\Rsx::Route($controller . '::' . $method);
return true;
} catch (\Exception $e) {
return false;
}
}
/**
* Check if a controller name is actually a SPA action class
*/
private function is_spa_action(string $class_name): bool
{
try {
// Use manifest to check if this JavaScript class extends Spa_Action
return \App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Spa_Action');
} catch (\Exception $e) {
// If manifest not available, class doesn't exist, or any error, return false
return false;
}
}
/**
* Check a file for violations
@@ -114,9 +134,9 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
// Pattern to match Rsx::Route and Rsx.Route calls (NOT plain Route())
// Matches both single and double parameter versions:
// - Rsx::Route('Controller') // PHP, defaults to 'index'
// - Rsx::Route('Controller', 'method') // PHP
// - Rsx::Route('Controller::method') // PHP
// - Rsx.Route('Controller') // JavaScript, defaults to 'index'
// - Rsx.Route('Controller', 'method') // JavaScript
// - Rsx.Route('Controller::method') // JavaScript
// Pattern for two parameters
$pattern_two_params = '/(?:Rsx::Route|Rsx\.Route)\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/';
@@ -190,11 +210,24 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
}
// Extract controller from ORIGINAL content, not sanitized
$controller = $original_matches[1][$index][0] ?? $matches[1][$index][0];
$method = 'index'; // Default to 'index'
$controller_string = $original_matches[1][$index][0] ?? $matches[1][$index][0];
// Check if using Controller::method syntax
if (str_contains($controller_string, '::')) {
[$controller, $method] = explode('::', $controller_string, 2);
} else {
$controller = $controller_string;
$method = 'index'; // Default to 'index'
}
// Skip if contains template variables like {$variable}
if (str_contains($controller, '{$') || str_contains($controller, '${')) {
if (str_contains($controller, '{$') || str_contains($controller, '${') ||
str_contains($method, '{$') || str_contains($method, '${')) {
continue;
}
// Skip if method starts with '#' - indicates unimplemented route
if (str_starts_with($method, '#')) {
continue;
}
@@ -240,10 +273,10 @@ class RouteExists_CodeQualityRule extends CodeQualityRule_Abstract
$suggestions[] = " - Create the controller if it doesn't exist";
$suggestions[] = " - Add the method with a #[Route] attribute";
$suggestions[] = "3. Use '#' prefix for unimplemented routes (recommended):";
$suggestions[] = " - Use Rsx::Route('Controller', '#index') for unimplemented index methods";
$suggestions[] = " - Use Rsx::Route('Controller', '#method_name') for other unimplemented methods";
$suggestions[] = " - Use Rsx::Route('Controller::#index') for unimplemented index methods";
$suggestions[] = " - Use Rsx::Route('Controller::#method_name') for other unimplemented methods";
$suggestions[] = " - Routes with '#' prefix will generate '#' URLs and bypass this validation";
$suggestions[] = " - Example: Rsx::Route('Backend_Users_Controller', '#index')";
$suggestions[] = " - Example: Rsx::Route('Backend_Users_Controller::#index')";
return implode("\n", $suggestions);
}

View File

@@ -92,7 +92,7 @@ class SubclassNaming_CodeQualityRule extends CodeQualityRule_Abstract
}
// Also check if the parent of parent is exempt - for deeper inheritance
// E.g., if Widget extends Jqhtml_Component, and Dynamic_Widget extends Widget
// E.g., if Widget extends Component, and Dynamic_Widget extends Widget
// Dynamic_Widget should match Widget's suffix
$parent_of_parent = $this->get_parent_class($parent_class_simple);
if ($parent_of_parent && !in_array($parent_of_parent, $suffix_exempt_classes)) {

View File

@@ -54,6 +54,11 @@ class DefensiveCoding_CodeQualityRule extends CodeQualityRule_Abstract
foreach ($lines as $line_num => $line) {
$line_number = $line_num + 1;
// Skip if line has exception comment
if (str_contains($line, '@' . $this->get_id() . '-EXCEPTION')) {
continue;
}
// Skip comments
$trimmed_line = trim($line);
if (str_starts_with($trimmed_line, '//') || str_starts_with($trimmed_line, '*')) {
@@ -174,7 +179,7 @@ class DefensiveCoding_CodeQualityRule extends CodeQualityRule_Abstract
" if (result && result.redirect) // OK - result may have optional redirect\n" .
" if (error && error.message) // OK - polymorphic error handling\n" .
" if (options && options.callback) // OK - optional configuration\n\n" .
"NOTE: Core guaranteed classes (Rsx, Modal, Jqhtml_Component, etc.) should NEVER be checked - let failures happen loudly during development.";
"NOTE: Core guaranteed classes (Rsx, Modal, Component, etc.) should NEVER be checked - let failures happen loudly during development.";
$this->add_violation(
$file_path,

View File

@@ -39,7 +39,7 @@ class DirectAjaxApi_CodeQualityRule extends CodeQualityRule_Abstract
* Should use:
* await Controller.action(params)
* Or:
* await Ajax.call(Rsx.Route('Controller', 'action'), params)
* await Ajax.call(Rsx.Route('Controller::action'), params)
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{

View File

@@ -7,7 +7,7 @@ use App\RSpade\CodeQuality\RuntimeChecks\YoureDoingItWrongException;
use App\RSpade\Core\Cache\RsxCache;
/**
* Check Jqhtml_Component implementations for common AI agent mistakes
* Check Component implementations for common AI agent mistakes
* Validates that components follow correct patterns
*/
class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abstract
@@ -24,7 +24,7 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
public function get_description(): string
{
return 'Validates Jqhtml_Component subclasses follow correct patterns';
return 'Validates Component subclasses follow correct patterns';
}
public function get_file_patterns(): array
@@ -47,8 +47,8 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
return;
}
// Skip if not a Jqhtml_Component subclass
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Jqhtml_Component') {
// Skip if not a Component subclass
if (!isset($metadata['extends']) || $metadata['extends'] !== 'Component') {
return;
}
@@ -91,10 +91,10 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
$error_message .= "Class: {$class_name}\n\n";
$error_message .= "Jqhtml components should not define a render() method.\n\n";
$error_message .= "PROBLEM:\n";
$error_message .= "The render() method is not part of the Jqhtml_Component lifecycle.\n";
$error_message .= "The render() method is not part of the Component lifecycle.\n";
$error_message .= "Jqhtml components use template files (.jqhtml) for rendering.\n\n";
$error_message .= "INCORRECT:\n";
$error_message .= " class My_Component extends Jqhtml_Component {\n";
$error_message .= " class My_Component extends Component {\n";
$error_message .= " render() {\n";
$error_message .= " return '<div>...</div>';\n";
$error_message .= " }\n";
@@ -105,7 +105,7 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
$error_message .= " <%= content() %>\n";
$error_message .= " </div>\n\n";
$error_message .= " // JavaScript class handles logic:\n";
$error_message .= " class My_Component extends Jqhtml_Component {\n";
$error_message .= " class My_Component extends Component {\n";
$error_message .= " on_ready() {\n";
$error_message .= " // Component logic here\n";
$error_message .= " }\n";
@@ -136,13 +136,13 @@ class JqhtmlComponentImplementation_CodeQualityRule extends CodeQualityRule_Abst
$error_message .= "The method '{$method_name}()' should be 'on_{$method_name}()'.\n";
$error_message .= "Jqhtml components use specific lifecycle method names.\n\n";
$error_message .= "INCORRECT:\n";
$error_message .= " class My_Component extends Jqhtml_Component {\n";
$error_message .= " class My_Component extends Component {\n";
$error_message .= " create() { ... } // Wrong\n";
$error_message .= " load() { ... } // Wrong\n";
$error_message .= " ready() { ... } // Wrong\n";
$error_message .= " }\n\n";
$error_message .= "CORRECT:\n";
$error_message .= " class My_Component extends Jqhtml_Component {\n";
$error_message .= " class My_Component extends Component {\n";
$error_message .= " on_create() { ... } // Correct\n";
$error_message .= " on_load() { ... } // Correct\n";
$error_message .= " on_ready() { ... } // Correct\n";

View File

@@ -158,7 +158,7 @@ const walk = require('acorn-walk');
// Classes that are Jqhtml components
const JQHTML_COMPONENTS = new Set([
'Jqhtml_Component', '_Base_Jqhtml_Component', 'Component'
'Component', '_Base_Jqhtml_Component', 'Component'
]);
function analyzeFile(filePath) {
@@ -182,7 +182,7 @@ function analyzeFile(filePath) {
let currentClass = null;
let inOnCreate = false;
// Helper to check if a class extends Jqhtml_Component
// Helper to check if a class extends Component
function isJqhtmlComponent(extendsClass) {
if (!extendsClass) return false;
return JQHTML_COMPONENTS.has(extendsClass) ||

View File

@@ -66,7 +66,7 @@ class JqhtmlOnLoadData_CodeQualityRule extends CodeQualityRule_Abstract
// Check each class to see if it's a JQHTML component
foreach ($js_classes as $class_name) {
// Use Manifest to check inheritance (handles indirect inheritance)
if (!Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component') &&
if (!Manifest::js_is_subclass_of($class_name, 'Component') &&
!Manifest::js_is_subclass_of($class_name, 'Component')) {
continue;
}

View File

@@ -34,7 +34,7 @@ class JqhtmlOnLoadDom_CodeQualityRule extends CodeQualityRule_Abstract
}
/**
* Check for DOM access in on_load methods of Jqhtml_Component subclasses
* Check for DOM access in on_load methods of Component subclasses
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
@@ -66,7 +66,7 @@ class JqhtmlOnLoadDom_CodeQualityRule extends CodeQualityRule_Abstract
// Check each class to see if it's a JQHTML component
foreach ($js_classes as $class_name) {
// Use Manifest to check inheritance (handles indirect inheritance)
if (!Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component') &&
if (!Manifest::js_is_subclass_of($class_name, 'Component') &&
!Manifest::js_is_subclass_of($class_name, 'Component')) {
continue;
}

View File

@@ -34,7 +34,7 @@ class JqhtmlRenderOverride_CodeQualityRule extends CodeQualityRule_Abstract
}
/**
* Check for render() method override in Jqhtml_Component subclasses
* Check for render() method override in Component subclasses
*/
public function check(string $file_path, string $contents, array $metadata = []): void
{
@@ -66,7 +66,7 @@ class JqhtmlRenderOverride_CodeQualityRule extends CodeQualityRule_Abstract
// Check each class to see if it's a JQHTML component
foreach ($js_classes as $class_name) {
// Use Manifest to check inheritance (handles indirect inheritance)
if (!Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component') &&
if (!Manifest::js_is_subclass_of($class_name, 'Component') &&
!Manifest::js_is_subclass_of($class_name, 'Component')) {
continue;
}

View File

@@ -91,8 +91,8 @@ class LifecycleMethodsStatic_CodeQualityRule extends CodeQualityRule_Abstract
// Get class name
$class_name = $metadata['class'];
// Only check classes that extend Jqhtml_Component
if (!\App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component')) {
// Only check classes that extend Component
if (!\App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Component')) {
return;
}

View File

@@ -64,7 +64,7 @@ class JqhtmlComponentNaming_CodeQualityRule extends CodeQualityRule_Abstract
$this->check_jqhtml_file($file_path, $contents);
}
// Check .js files for classes extending Jqhtml_Component
// Check .js files for classes extending Component
if (str_ends_with($file_path, '.js')) {
$this->check_javascript_file($file_path, $contents, $metadata);
}
@@ -101,7 +101,7 @@ class JqhtmlComponentNaming_CodeQualityRule extends CodeQualityRule_Abstract
}
/**
* Check JavaScript files for Jqhtml_Component subclasses
* Check JavaScript files for Component subclasses
*/
private function check_javascript_file(string $file_path, string $contents, array $metadata = []): void
{
@@ -131,7 +131,7 @@ class JqhtmlComponentNaming_CodeQualityRule extends CodeQualityRule_Abstract
// Check each class to see if it's a JQHTML component
foreach ($class_definitions as $class_name => $line_num) {
// Use Manifest to check if this is a JQHTML component (handles indirect inheritance)
if (Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component')) {
if (Manifest::js_is_subclass_of($class_name, 'Component')) {
// Check if first character is not uppercase
if (!ctype_upper($class_name[0])) {
$this->add_violation(

View File

@@ -67,10 +67,10 @@ class JqhtmlEventPreventDefault_CodeQualityRule extends CodeQualityRule_Abstract
return;
}
// Find the first class that extends Jqhtml_Component
// Find the first class that extends Component
$class_name = null;
foreach ($js_classes as $js_class) {
if (Manifest::js_is_subclass_of($js_class, 'Jqhtml_Component')) {
if (Manifest::js_is_subclass_of($js_class, 'Component')) {
$class_name = $js_class;
break;
}

View File

@@ -171,7 +171,7 @@ class JqhtmlInlineScript_CodeQualityRule extends CodeQualityRule_Abstract
REQUIRED STEPS:
1. Create a JavaScript file: {$js_path}
2. Name the ES6 class exactly: {$class_name}
3. Extend Jqhtml_Component base class
3. Extend Component base class
4. Implement lifecycle methods: on_create(), on_load(), on_ready()
EXAMPLE IMPLEMENTATION for {$js_filename}:
@@ -179,7 +179,7 @@ EXAMPLE IMPLEMENTATION for {$js_filename}:
/**
* Component class for {$class_name}
*/
class {$class_name} extends Jqhtml_Component {
class {$class_name} extends Component {
/**
* Called when component instance is created
* Use for initial setup and event binding
@@ -229,7 +229,7 @@ class {$class_name} extends Jqhtml_Component {
KEY CONVENTIONS:
- Class name MUST match the <Define:{$class_name}> in the .jqhtml file
- MUST extend Jqhtml_Component base class
- MUST extend Component base class
- Use lifecycle methods: on_create(), on_load(), on_ready()
- Access component element via this.\$
- Bind events using \$onclick, \$onchange, etc. in template

View File

@@ -92,11 +92,11 @@ class FilenameClassMatch_CodeQualityRule extends CodeQualityRule_Abstract
{
$filename_without_ext = pathinfo($filename, PATHINFO_FILENAME);
// Check if this is a JavaScript class extending Jqhtml_Component
// Check if this is a JavaScript class extending Component
$is_jqhtml_component = false;
if ($extension === 'js') {
try {
$is_jqhtml_component = \App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Jqhtml_Component');
$is_jqhtml_component = \App\RSpade\Core\Manifest\Manifest::js_is_subclass_of($class_name, 'Component');
} catch (\Exception $e) {
// Class not in manifest or not a JS class, treat as regular class
$is_jqhtml_component = false;

View File

@@ -12,10 +12,10 @@ use App\RSpade\CodeQuality\Rules\CodeQualityRule_Abstract;
* to check if a class extends another, since regex can't detect indirect inheritance.
*
* Example of incorrect pattern:
* preg_match('/class\s+\w+\s+extends\s+Jqhtml_Component/', $contents)
* preg_match('/class\s+\w+\s+extends\s+Component/', $contents)
*
* Should be:
* Manifest::php_is_subclass_of($class_name, 'Jqhtml_Component')
* Manifest::php_is_subclass_of($class_name, 'Component')
*/
class Code_Quality_Meta_Inheritance_CodeQualityRule extends CodeQualityRule_Abstract
{
@@ -92,7 +92,7 @@ class Code_Quality_Meta_Inheritance_CodeQualityRule extends CodeQualityRule_Abst
"If checking JavaScript classes: Use Manifest::js_is_subclass_of(\$class_name, 'BaseClass')\n" .
"Examples:\n" .
" PHP: if (Manifest::php_is_subclass_of(\$metadata['class'], 'Rsx_Model_Abstract')) { ... }\n" .
" JS: if (Manifest::js_is_subclass_of(\$class_name, 'Jqhtml_Component')) { ... }",
" JS: if (Manifest::js_is_subclass_of(\$class_name, 'Component')) { ... }",
'high'
);
}

View File

@@ -93,6 +93,12 @@ class DbTableUsage_CodeQualityRule extends CodeQualityRule_Abstract
continue;
}
// Skip framework internal tables (prefixed with underscore)
// These are low-level system tables managed directly for performance
if (str_starts_with($table_name, '_')) {
continue;
}
// Use original line for display in error message
$original_line = $original_lines[$line_num] ?? $sanitized_line;

View File

@@ -276,10 +276,10 @@ class HardcodedUrlInRedirect_CodeQualityRule extends CodeQualityRule_Abstract
protected function _generate_rsx_suggestion(string $class_name, string $method_name, array $params): string
{
if (empty($params)) {
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}'));";
return "return redirect(Rsx::Route('{$class_name}::{$method_name}'));";
} else {
$params_str = $this->_format_php_array($params);
return "return redirect(Rsx::Route('{$class_name}', '{$method_name}', {$params_str}));";
return "return redirect(Rsx::Route('{$class_name}::{$method_name}', {$params_str}));";
}
}

View File

@@ -141,9 +141,9 @@ class RouteQueryConcatenation_CodeQualityRule extends CodeQualityRule_Abstract
$line,
"Query parameters should be passed as the third argument to Rsx::Route() as an array.\n\n" .
"WRONG:\n" .
" Rsx::Route('Controller', 'method') . '?param=value&other=test'\n\n" .
" Rsx::Route('Controller::method') . '?param=value&other=test'\n\n" .
"CORRECT:\n" .
" Rsx::Route('Controller', 'method', ['param' => 'value', 'other' => 'test'])\n\n" .
" Rsx::Route('Controller::method', ['param' => 'value', 'other' => 'test'])\n\n" .
"The framework will automatically URL-encode the parameters and construct the query string properly."
);
}

View File

@@ -73,7 +73,7 @@ class ManifestErrors
$error_message .= "❌ BAD: class MyComponent extends window.BaseComponent {}\n";
$error_message .= "✅ GOOD: class MyComponent extends BaseComponent {}\n\n";
$error_message .= "❌ BAD: class Widget extends jqhtml.Component {}\n";
$error_message .= "✅ GOOD: class Widget extends Jqhtml_Component {}\n\n";
$error_message .= "✅ GOOD: class Widget extends Component {}\n\n";
$error_message .= "This restriction maintains consistency and enables proper class hierarchy tracking.\n";
$error_message .= "==========================================";
@@ -106,7 +106,7 @@ class ManifestErrors
int $line_num,
string $class_name
): void {
$error_message = "Fatal: Incorrect Jqhtml_Component implementation detected.\n\n";
$error_message = "Fatal: Incorrect Component implementation detected.\n\n";
$error_message .= "File: {$file}\n";
$error_message .= "Line {$line_num}: Found render() method\n\n";
$error_message .= "PROBLEM: The render() method should not exist in JavaScript component classes.\n\n";
@@ -137,10 +137,10 @@ class ManifestErrors
int $line_num,
string $method
): void {
$error_message = "Fatal: Incorrect Jqhtml_Component lifecycle method detected.\n\n";
$error_message = "Fatal: Incorrect Component lifecycle method detected.\n\n";
$error_message .= "File: {$file}\n";
$error_message .= "Line {$line_num}: Found '{$method}()' method\n\n";
$error_message .= "PROBLEM: Jqhtml_Component lifecycle methods must be prefixed with 'on_'\n\n";
$error_message .= "PROBLEM: Component lifecycle methods must be prefixed with 'on_'\n\n";
$error_message .= "SOLUTION: Rename the method:\n";
$error_message .= "- '{$method}()' should be 'on_{$method}()'\n\n";
$error_message .= "CORRECT LIFECYCLE METHODS:\n";

View File

@@ -0,0 +1,50 @@
<?php
namespace App\RSpade\Commands\Restricted;
use Illuminate\Console\Command;
class Session_Cleanup_Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'session:cleanup';
/**
* The console command description.
*
* @var string
*/
protected $description = '[RESTRICTED] Session cleanup runs automatically via scheduled task';
/**
* Hide this command from artisan list
*
* @var bool
*/
protected $hidden = true;
/**
* Execute the console command.
*/
public function handle()
{
$this->error('[RESTRICTED] This command is disabled.');
$this->info('');
$this->info('Session cleanup runs automatically via scheduled task:');
$this->info(' Session_Cleanup_Service::cleanup_sessions()');
$this->info(' Schedule: Daily at 3 AM');
$this->info('');
$this->info('Cleanup rules:');
$this->info(' - Logged-in sessions (login_user_id set): Delete if older than 365 days');
$this->info(' - Anonymous sessions (login_user_id null): Delete if older than 14 days');
$this->info('');
$this->info('To manually trigger cleanup:');
$this->info(' php artisan rsx:task:run Session_Cleanup_Service cleanup_sessions');
return 1;
}
}

View File

@@ -217,7 +217,7 @@ JQHTML;
* 2. on_load() - Fetch data from APIs (parallel execution, no DOM modifications)
* 3. on_ready() - Component fully initialized, runs bottom-up through component tree
*/
class {$class_name} extends Jqhtml_Component {
class {$class_name} extends Component {
/**
* Called after render, quick UI setup (bottom-up)
* Use for: Initial state, event bindings, showing loading indicators

View File

@@ -78,7 +78,7 @@ class RsxStorageCleanupCommand extends FrameworkDeveloperCommand
->get();
// Find orphaned storage
$orphaned_storage = DB::table('file_storage')
$orphaned_storage = DB::table('_file_storage')
->leftJoin('file_attachments', 'file_storage.id', '=', 'file_attachments.file_storage_id')
->whereNull('file_attachments.id')
->select('file_storage.*')

View File

@@ -116,7 +116,7 @@ class RsxStorageStatsCommand extends FrameworkDeveloperCommand
}
// Orphaned storage (no attachments)
$orphaned_storage = DB::table('file_storage')
$orphaned_storage = DB::table('_file_storage')
->leftJoin('file_attachments', 'file_storage.id', '=', 'file_attachments.file_storage_id')
->whereNull('file_attachments.id')
->count();

View File

@@ -88,29 +88,24 @@ class Rsx_Routes_Command extends Command
{
$all_routes = [];
foreach ($routes as $type => $type_routes) {
foreach ($type_routes as $pattern => $methods) {
foreach ($methods as $method => $handlers) {
// Handle new structure where each method can have multiple handlers
if (!isset($handlers[0])) {
// Old structure - single handler
$handlers = [$handlers];
}
// New unified structure: $routes[$pattern] => route_data
foreach ($routes as $pattern => $route_data) {
// Each route can support multiple HTTP methods
$methods = $route_data['methods'] ?? ['GET'];
foreach ($handlers as $handler) {
$all_routes[] = [
'type' => $type,
'pattern' => $pattern,
'method' => $method,
'class' => $handler['class'] ?? 'N/A',
'action' => $handler['method'] ?? 'N/A',
'middleware' => $this->get_middleware($handler),
'cache' => $this->get_cache_ttl($handler),
'name' => $handler['name'] ?? null,
'attributes' => $handler['attributes'] ?? [],
];
}
}
foreach ($methods as $method) {
$all_routes[] = [
'type' => $route_data['type'] ?? 'standard',
'pattern' => $pattern,
'method' => $method,
'class' => $route_data['class'] ?? 'N/A',
'action' => $route_data['method'] ?? 'N/A',
'middleware' => $this->get_middleware($route_data),
'cache' => $this->get_cache_ttl($route_data),
'name' => $route_data['name'] ?? null,
'file' => $route_data['file'] ?? null,
'js_action_class' => $route_data['js_action_class'] ?? null,
];
}
}
@@ -230,24 +225,23 @@ class Rsx_Routes_Command extends Command
// Build key-value pairs
$parts[] = 'path=' . $route['pattern'];
$parts[] = 'method=' . $route['method'];
$parts[] = 'type=' . $route['type'];
$parts[] = 'controller=' . class_basename($route['class']);
$parts[] = 'action=' . $route['action'];
// Get file path if we can determine it
if ($route['class'] !== 'N/A') {
try {
$file = Manifest::php_find_class(class_basename($route['class']));
if ($file) {
$parts[] = 'file=' . $file;
}
} catch (Exception $e) {
// Class not found in manifest, skip file path
}
// File path is now directly in route data
if (!empty($route['file'])) {
$parts[] = 'file=' . $route['file'];
}
// Add SPA-specific fields
if (!empty($route['js_action_class'])) {
$parts[] = 'js_action=' . $route['js_action_class'];
}
// Add optional fields if present
if ($route['middleware']) {
$parts[] = 'middleware=' . $route['middleware'];
$parts[] = 'auth=' . $route['middleware'];
}
if ($route['cache']) {
$parts[] = 'cache=' . $route['cache'] . 's';
@@ -260,23 +254,28 @@ class Rsx_Routes_Command extends Command
}
/**
* Get middleware string from handler
* Get middleware string from route data
*
* @param array $handler
* @param array $route_data
* @return string|null
*/
protected function get_middleware($handler)
protected function get_middleware($route_data)
{
$attributes = $handler['attributes'] ?? [];
// Check if route has require (Auth) attributes
$require = $route_data['require'] ?? [];
foreach ($attributes as $attr) {
if (isset($attr['class']) && str_ends_with($attr['class'], 'Middleware')) {
$middleware = $attr['args']['middleware'] ?? $attr['args'][0] ?? null;
if (is_array($middleware)) {
return implode(',', $middleware);
if (!empty($require)) {
// Flatten require array (it's an array of arrays)
$perms = [];
foreach ($require as $req) {
if (is_array($req)) {
$perms = array_merge($perms, $req);
} else {
$perms[] = $req;
}
return $middleware;
}
if (!empty($perms)) {
return implode(',', $perms);
}
}
@@ -284,21 +283,15 @@ class Rsx_Routes_Command extends Command
}
/**
* Get cache TTL from handler
* Get cache TTL from route data
*
* @param array $handler
* @param array $route_data
* @return int|null
*/
protected function get_cache_ttl($handler)
protected function get_cache_ttl($route_data)
{
$attributes = $handler['attributes'] ?? [];
foreach ($attributes as $attr) {
if (isset($attr['class']) && str_ends_with($attr['class'], 'Cache')) {
return $attr['args']['ttl'] ?? $attr['args'][0] ?? null;
}
}
// Cache info would be in route metadata if it exists
// Currently not tracked in new structure
return null;
}
}

View File

@@ -0,0 +1,375 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Rsx;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\RSpade\Core\Task\Task;
use App\RSpade\Core\Task\Task_Instance;
use App\RSpade\Core\Task\Task_Status;
use App\RSpade\Core\Task\Task_Lock;
use App\RSpade\Core\Task\Cron_Parser;
use Symfony\Component\Process\Process;
/**
* Task Process Command
*
* Main worker manager for the task system. Handles:
* - Processing queued tasks
* - Executing scheduled tasks
* - Spawning worker processes
* - Detecting and recovering stuck tasks
*
* This command is designed to run via cron every minute:
* * * * * * cd /var/www/html && php artisan rsx:task:process
*
* It will:
* 1. Check for stuck tasks and mark them as failed
* 2. Process scheduled tasks that are due
* 3. Spawn workers for queued tasks (up to concurrency limits)
* 4. Exit quickly if no work to do
*/
class Task_Process_Command extends Command
{
protected $signature = 'rsx:task:process
{--queue= : Process only this specific queue}
{--once : Process one task and exit (for testing)}
{--force-scheduled : Run all scheduled tasks immediately (ignore next_run_at)}';
protected $description = 'Process queued and scheduled tasks (run via cron every minute)';
public function handle()
{
$specific_queue = $this->option('queue');
$once_mode = $this->option('once');
$this->info('[TASK PROCESSOR] Starting task processor');
// Step 1: Detect and recover stuck tasks
$this->detect_stuck_tasks();
// Step 2: Process scheduled tasks
if (!$specific_queue) {
$this->process_scheduled_tasks();
}
// Step 3: Spawn workers for queued tasks
$this->spawn_workers_for_queues($specific_queue);
// If in once mode, process one task directly (for testing)
if ($once_mode) {
$this->process_one_task($specific_queue ?? 'default');
}
$this->info('[TASK PROCESSOR] Task processor complete');
return 0;
}
/**
* Detect and mark stuck tasks as failed
*
* A task is considered stuck if:
* - Status is 'running'
* - Started more than timeout seconds ago
* - Worker PID is dead or missing
*/
private function detect_stuck_tasks(): void
{
$cleanup_after = config('rsx.tasks.cleanup_stuck_after', 1800);
$stuck_tasks = DB::table('_task_queue')
->where('status', Task_Status::RUNNING)
->where('started_at', '<', now()->subSeconds($cleanup_after))
->get();
foreach ($stuck_tasks as $task) {
// Check if worker process is still alive
$worker_alive = $task->worker_pid && posix_kill($task->worker_pid, 0);
if (!$worker_alive) {
$this->warn("[STUCK TASK] Marking task {$task->id} as failed (worker PID {$task->worker_pid} not responding)");
DB::table('_task_queue')
->where('id', $task->id)
->update([
'status' => Task_Status::FAILED,
'error' => 'Task stuck - worker process not responding',
'completed_at' => now(),
'updated_at' => now(),
]);
}
}
}
/**
* Process scheduled tasks that are due
*
* Scans manifest for #[Schedule] attributes and ensures each has a database record.
* Creates pending task instances for any scheduled tasks that are due.
*/
private function process_scheduled_tasks(): void
{
$force_all = $this->option('force-scheduled');
// Get all scheduled tasks from manifest
$scheduled_tasks = Task::get_scheduled_tasks();
if (empty($scheduled_tasks)) {
return;
}
$this->info("[SCHEDULED] Found " . count($scheduled_tasks) . " scheduled task(s) in manifest");
foreach ($scheduled_tasks as $task_def) {
$this->process_scheduled_task($task_def, $force_all);
}
}
/**
* Process a single scheduled task definition
*
* @param array $task_def Task definition from manifest
* @param bool $force_run If true, run immediately regardless of schedule
*/
private function process_scheduled_task(array $task_def, bool $force_run): void
{
$class = $task_def['class'];
$method = $task_def['method'];
$cron_expression = $task_def['cron_expression'];
$queue = $task_def['queue'];
// Check if we have a record for this scheduled task
$existing = DB::table('_task_queue')
->where('class', $class)
->where('method', $method)
->where('queue', $queue)
->whereNotNull('next_run_at')
->whereIn('status', [Task_Status::PENDING, Task_Status::RUNNING])
->first();
if (!$existing) {
// Create initial scheduled task record
$parser = new Cron_Parser($cron_expression);
$next_run_at = $parser->get_next_run_time();
$this->info("[SCHEDULED] Registering new scheduled task: {$class}::{$method}");
$this->info("[SCHEDULED] Cron: {$cron_expression}");
$this->info("[SCHEDULED] Next run: " . date('Y-m-d H:i:s', $next_run_at));
DB::table('_task_queue')->insert([
'class' => $class,
'method' => $method,
'queue' => $queue,
'status' => Task_Status::PENDING,
'params' => json_encode([]),
'next_run_at' => date('Y-m-d H:i:s', $next_run_at),
'created_at' => now(),
'updated_at' => now(),
]);
return;
}
// Check if task is due to run
$next_run_timestamp = strtotime($existing->next_run_at);
$is_due = $next_run_timestamp <= time();
if ($force_run || $is_due) {
// Task is due - dispatch it
$this->info("[SCHEDULED] Dispatching scheduled task: {$class}::{$method}");
// Create a new pending task instance
$task_id = DB::table('_task_queue')->insertGetId([
'class' => $class,
'method' => $method,
'queue' => $queue,
'status' => Task_Status::PENDING,
'params' => json_encode([]),
'scheduled_for' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
// Calculate next run time and update the scheduled task record
$parser = new Cron_Parser($cron_expression);
$next_run_at = $parser->get_next_run_time();
DB::table('_task_queue')
->where('id', $existing->id)
->update([
'next_run_at' => date('Y-m-d H:i:s', $next_run_at),
'updated_at' => now(),
]);
$this->info("[SCHEDULED] Created task instance ID: {$task_id}");
$this->info("[SCHEDULED] Next run updated to: " . date('Y-m-d H:i:s', $next_run_at));
}
}
/**
* Spawn workers for queued tasks based on concurrency limits
*
* @param string|null $specific_queue If set, only process this queue
*/
private function spawn_workers_for_queues(?string $specific_queue): void
{
$queue_config = config('rsx.tasks.queues', []);
$global_max = config('rsx.tasks.global_max_workers', 1);
// Count currently running workers across all queues
$total_running = DB::table('_task_queue')
->where('status', Task_Status::RUNNING)
->count();
if ($total_running >= $global_max) {
$this->info("[WORKER SPAWN] Global limit reached ({$total_running}/{$global_max})");
return;
}
// Get queues to process
$queues_to_process = $specific_queue
? [$specific_queue => $queue_config[$specific_queue] ?? ['max_workers' => 1]]
: $queue_config;
foreach ($queues_to_process as $queue_name => $queue_settings) {
$max_workers = $queue_settings['max_workers'] ?? 1;
// Count running workers for this queue
$queue_running = DB::table('_task_queue')
->where('queue', $queue_name)
->where('status', Task_Status::RUNNING)
->count();
// Count pending tasks for this queue (exclude scheduled task records)
$queue_pending = DB::table('_task_queue')
->where('queue', $queue_name)
->where('status', Task_Status::PENDING)
->whereNull('next_run_at') // Exclude scheduled task records
->where(function ($query) {
$query->whereNull('scheduled_for')
->orWhere('scheduled_for', '<=', now());
})
->count();
if ($queue_pending === 0) {
continue;
}
// Calculate how many workers we can spawn
$can_spawn_global = $global_max - $total_running;
$can_spawn_queue = $max_workers - $queue_running;
$can_spawn = min($can_spawn_global, $can_spawn_queue, $queue_pending);
if ($can_spawn > 0) {
$this->info("[WORKER SPAWN] Spawning {$can_spawn} worker(s) for queue '{$queue_name}'");
for ($i = 0; $i < $can_spawn; $i++) {
$this->spawn_worker($queue_name);
$total_running++;
}
}
}
}
/**
* Spawn a worker process for a queue
*
* @param string $queue_name Queue to process
*/
private function spawn_worker(string $queue_name): void
{
$process = new Process([
'php',
base_path('artisan'),
'rsx:task:worker',
'--queue=' . $queue_name,
]);
$process->setTimeout(null);
$process->start();
$this->info("[WORKER SPAWN] Started worker process {$process->getPid()} for queue '{$queue_name}'");
}
/**
* Process one task directly (for testing)
*
* @param string $queue_name Queue to process from
*/
private function process_one_task(string $queue_name): void
{
$this->info("[ONCE MODE] Processing one task from queue '{$queue_name}'");
// Use lock to ensure atomic task selection
$lock = new Task_Lock("task_queue_{$queue_name}");
if (!$lock->acquire()) {
$this->warn("[ONCE MODE] Could not acquire lock for queue '{$queue_name}'");
return;
}
try {
// Select next pending task (exclude scheduled task records)
$task_row = DB::table('_task_queue')
->where('queue', $queue_name)
->where('status', Task_Status::PENDING)
->whereNull('next_run_at') // Exclude scheduled task records
->where(function ($query) {
$query->whereNull('scheduled_for')
->orWhere('scheduled_for', '<=', now());
})
->orderBy('created_at', 'asc')
->lockForUpdate()
->first();
if (!$task_row) {
$this->info("[ONCE MODE] No pending tasks in queue '{$queue_name}'");
$lock->release();
return;
}
// Mark as running
DB::table('_task_queue')
->where('id', $task_row->id)
->update([
'status' => Task_Status::RUNNING,
'started_at' => now(),
'worker_pid' => getmypid(),
'updated_at' => now(),
]);
$lock->release();
// Execute the task
$this->info("[ONCE MODE] Executing task {$task_row->id}: {$task_row->class}::{$task_row->method}");
$task_instance = Task_Instance::find($task_row->id);
try {
$class = $task_row->class;
$method = $task_row->method;
$params = json_decode($task_row->params, true) ?? [];
$result = $class::$method($task_instance, $params);
$task_instance->mark_completed($result);
$this->info("[ONCE MODE] Task {$task_row->id} completed successfully");
} catch (\Exception $e) {
$task_instance->mark_failed($e->getMessage());
$this->error("[ONCE MODE] Task {$task_row->id} failed: " . $e->getMessage());
}
} finally {
if ($lock->is_locked()) {
$lock->release();
}
}
}
}

View File

@@ -0,0 +1,189 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Commands\Rsx;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\RSpade\Core\Task\Task_Instance;
use App\RSpade\Core\Task\Task_Status;
use App\RSpade\Core\Task\Task_Lock;
/**
* Task Worker Command
*
* Background worker process that continuously processes tasks from a queue.
* Spawned by Task_Process_Command, runs until no more tasks or timeout.
*
* This command:
* 1. Acquires lock for queue
* 2. Selects next pending task (SELECT FOR UPDATE)
* 3. Marks task as running
* 4. Executes the task
* 5. Marks task as completed/failed
* 6. Repeats until no more tasks
*
* Exit conditions:
* - No more pending tasks
* - Maximum execution time reached (5 minutes)
* - Fatal error
*/
class Task_Worker_Command extends Command
{
protected $signature = 'rsx:task:worker
{--queue=default : Queue name to process}
{--max-time=300 : Maximum execution time in seconds (default: 5 minutes)}';
protected $description = 'Background worker for processing queued tasks';
private int $start_time;
private int $max_time;
private int $tasks_processed = 0;
public function handle()
{
$queue_name = $this->option('queue');
$this->max_time = (int) $this->option('max-time');
$this->start_time = time();
$this->info("[WORKER] Started worker for queue '{$queue_name}' (PID: " . getmypid() . ")");
// Process tasks until none remain or timeout
while (true) {
// Check if we've exceeded max execution time
if ((time() - $this->start_time) >= $this->max_time) {
$this->info("[WORKER] Max execution time reached, exiting");
break;
}
// Try to process one task
$processed = $this->process_next_task($queue_name);
if (!$processed) {
// No more tasks to process
$this->info("[WORKER] No more pending tasks, exiting");
break;
}
$this->tasks_processed++;
}
$this->info("[WORKER] Worker finished. Processed {$this->tasks_processed} task(s)");
return 0;
}
/**
* Process the next pending task from the queue
*
* @param string $queue_name Queue to process from
* @return bool True if task was processed, false if no tasks available
*/
private function process_next_task(string $queue_name): bool
{
// Use lock to ensure atomic task selection
$lock = new Task_Lock("task_queue_{$queue_name}", 5);
if (!$lock->acquire()) {
$this->warn("[WORKER] Could not acquire lock for queue '{$queue_name}', retrying...");
sleep(1);
return true; // Retry
}
try {
// Select next pending task (exclude scheduled task records)
$task_row = DB::table('_task_queue')
->where('queue', $queue_name)
->where('status', Task_Status::PENDING)
->whereNull('next_run_at') // Exclude scheduled task records
->where(function ($query) {
$query->whereNull('scheduled_for')
->orWhere('scheduled_for', '<=', now());
})
->orderBy('created_at', 'asc')
->lockForUpdate()
->first();
if (!$task_row) {
// No tasks available
$lock->release();
return false;
}
// Mark as running
DB::table('_task_queue')
->where('id', $task_row->id)
->update([
'status' => Task_Status::RUNNING,
'started_at' => now(),
'worker_pid' => getmypid(),
'updated_at' => now(),
]);
$lock->release();
// Execute the task
$this->execute_task($task_row);
return true;
} catch (\Exception $e) {
$this->error("[WORKER] Error processing task: " . $e->getMessage());
if ($lock->is_locked()) {
$lock->release();
}
return false;
}
}
/**
* Execute a task
*
* @param object $task_row Task database row
*/
private function execute_task(object $task_row): void
{
$this->info("[WORKER] Executing task {$task_row->id}: {$task_row->class}::{$task_row->method}");
$task_instance = Task_Instance::find($task_row->id);
if (!$task_instance) {
$this->error("[WORKER] Could not load task instance for task {$task_row->id}");
return;
}
try {
$class = $task_row->class;
$method = $task_row->method;
$params = json_decode($task_row->params, true) ?? [];
// Check if class exists
if (!class_exists($class)) {
throw new \Exception("Class not found: {$class}");
}
// Check if method exists
if (!method_exists($class, $method)) {
throw new \Exception("Method not found: {$class}::{$method}");
}
// Execute the task method
$result = $class::$method($task_instance, $params);
// Mark as completed
$task_instance->mark_completed($result);
$this->info("[WORKER] Task {$task_row->id} completed successfully");
} catch (\Exception $e) {
// Mark as failed
$task_instance->mark_failed($e->getMessage());
$this->error("[WORKER] Task {$task_row->id} failed: " . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\RSpade\Commands\Thumbnails;
use Illuminate\Console\Command;
use App\RSpade\Core\Files\File_Thumbnail_Service;
class Thumbnails_Clean_Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'rsx:thumbnails:clean
{--preset : Clean only preset thumbnails}
{--dynamic : Clean only dynamic thumbnails}
{--all : Clean both preset and dynamic thumbnails (default)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Enforce thumbnail cache quotas by deleting oldest files until under limit (LRU eviction)';
/**
* Execute the console command.
*/
public function handle()
{
$clean_preset = $this->option('preset') || $this->option('all') || (!$this->option('preset') && !$this->option('dynamic'));
$clean_dynamic = $this->option('dynamic') || $this->option('all') || (!$this->option('preset') && !$this->option('dynamic'));
$total_deleted = 0;
$total_freed = 0;
if ($clean_preset) {
[$deleted, $freed] = $this->clean_directory_with_output('preset');
$total_deleted += $deleted;
$total_freed += $freed;
}
if ($clean_dynamic) {
[$deleted, $freed] = $this->clean_directory_with_output('dynamic');
$total_deleted += $deleted;
$total_freed += $freed;
}
if ($total_deleted > 0) {
$freed_mb = round($total_freed / 1024 / 1024, 2);
$this->info("Cleanup complete: {$total_deleted} files deleted, {$freed_mb} MB freed");
} else {
$this->info('No cleanup needed - all directories under quota');
}
return 0;
}
/**
* Clean a thumbnail directory with CLI output
*
* Wrapper around File_Thumbnail_Service::clean_directory() that adds CLI output.
*
* @param string $type Either 'preset' or 'dynamic'
* @return array [files_deleted, bytes_freed]
*/
protected function clean_directory_with_output($type)
{
$quota_key = $type . '_max_bytes';
$max_bytes = config("rsx.thumbnails.quotas.{$quota_key}");
$dir = storage_path("rsx-thumbnails/{$type}/");
if (!is_dir($dir)) {
$this->warn("Directory not found: {$dir}");
return [0, 0];
}
// Get current stats before cleanup
$before_count = count(glob($dir . '*.webp'));
$before_size = array_sum(array_map('filesize', glob($dir . '*.webp')));
$current_mb = round($before_size / 1024 / 1024, 2);
$quota_mb = round($max_bytes / 1024 / 1024, 2);
$this->info("[$type] Current usage: {$current_mb} MB / {$quota_mb} MB ({$before_count} files)");
// Run cleanup via service
[$deleted, $freed] = File_Thumbnail_Service::clean_directory($type);
if ($deleted > 0) {
$over_by = round(($before_size - $max_bytes) / 1024 / 1024, 2);
$freed_mb = round($freed / 1024 / 1024, 2);
$final_mb = round(($before_size - $freed) / 1024 / 1024, 2);
$this->warn("[$type] Was over quota by {$over_by} MB");
$this->info("[$type] Deleted {$deleted} files, freed {$freed_mb} MB, new usage: {$final_mb} MB");
}
return [$deleted, $freed];
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace App\RSpade\Commands\Thumbnails;
use Illuminate\Console\Command;
use App\RSpade\Core\Files\File_Attachment_Model;
use App\RSpade\Core\Files\File_Attachment_Controller;
class Thumbnails_Generate_Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'rsx:thumbnails:generate
{--preset= : Generate specific preset for all attachments (e.g., --preset=profile)}
{--key= : Generate thumbnails for specific attachment key}
{--all : Generate all presets for all attachments (default if no options specified)}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Pre-generate preset thumbnails for attachments (useful for cache warming after deployment)';
/**
* Execute the console command.
*/
public function handle()
{
$preset_filter = $this->option('preset');
$key_filter = $this->option('key');
$generate_all = $this->option('all') || (!$preset_filter && !$key_filter);
// Get presets to generate
$presets = config('rsx.thumbnails.presets', []);
if (empty($presets)) {
$this->error('No thumbnail presets defined in config');
return 1;
}
$presets_to_generate = $presets;
if ($preset_filter) {
if (!isset($presets[$preset_filter])) {
$this->error("Preset '{$preset_filter}' not defined in config");
return 1;
}
$presets_to_generate = [$preset_filter => $presets[$preset_filter]];
}
// Get attachments to process
$query = File_Attachment_Model::query();
if ($key_filter) {
$query->where('key', $key_filter);
}
$attachments = $query->get();
if ($attachments->isEmpty()) {
$this->warn('No attachments found');
return 0;
}
$this->info("Generating thumbnails for " . count($attachments) . " attachment(s), " . count($presets_to_generate) . " preset(s)");
$generated_count = 0;
$skipped_count = 0;
$error_count = 0;
foreach ($attachments as $attachment) {
foreach ($presets_to_generate as $preset_name => $preset_config) {
try {
$this->generate_thumbnail($attachment, $preset_name, $preset_config);
$generated_count++;
} catch (\Exception $e) {
$this->error("Error generating {$preset_name} for {$attachment->key}: " . $e->getMessage());
$error_count++;
}
}
}
$this->info("Complete: {$generated_count} generated, {$skipped_count} skipped (cached), {$error_count} errors");
return $error_count > 0 ? 1 : 0;
}
/**
* Generate thumbnail for attachment/preset combination
*
* @param File_Attachment_Model $attachment
* @param string $preset_name
* @param array $preset_config
* @return void
*/
protected function generate_thumbnail($attachment, $preset_name, $preset_config)
{
$storage = $attachment->file_storage;
if (!$storage || !file_exists($storage->get_full_path())) {
throw new \Exception('File not found on disk');
}
// Generate cache filename
$cache_filename = File_Attachment_Controller::_get_cache_filename_preset(
$preset_name,
$storage->hash,
$attachment->file_extension
);
$cache_path = File_Attachment_Controller::_get_cache_path('preset', $cache_filename);
// Skip if already cached
if (file_exists($cache_path)) {
return;
}
$type = $preset_config['type'];
$width = $preset_config['width'];
$height = $preset_config['height'] ?? null;
// Generate thumbnail
if ($attachment->is_image()) {
$thumbnail_data = File_Attachment_Controller::__generate_thumbnail(
$storage->get_full_path(),
$type,
$width,
$height
);
} else {
// Use icon-based thumbnail for non-images
$thumbnail_data = \App\RSpade\Core\Files\File_Attachment_Icons::render_icon_as_thumbnail(
$attachment->file_extension,
$width,
$height ?? $width
);
}
// Save to cache
File_Attachment_Controller::_save_thumbnail_to_cache($cache_path, $thumbnail_data);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\RSpade\Commands\Thumbnails;
use Illuminate\Console\Command;
use App\RSpade\Core\Files\File_Thumbnail_Service;
class Thumbnails_Stats_Command extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'rsx:thumbnails:stats';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Display thumbnail cache usage statistics';
/**
* Execute the console command.
*/
public function handle()
{
$stats = File_Thumbnail_Service::get_statistics();
$this->info('Thumbnail Cache Statistics');
$this->info('');
// Preset thumbnails
$this->display_directory_stats('Preset Thumbnails', $stats['preset']);
$this->info('');
// Dynamic thumbnails
$this->display_directory_stats('Dynamic Thumbnails', $stats['dynamic']);
$this->info('');
// Breakdown by preset
if (!empty($stats['preset_breakdown'])) {
$this->display_preset_breakdown($stats['preset_breakdown']);
}
return 0;
}
/**
* Display statistics for a thumbnail directory
*
* @param string $title Display title
* @param array $stats Statistics from service
* @return void
*/
protected function display_directory_stats($title, $stats)
{
if (!$stats['exists']) {
$this->warn("{$title}: Directory not found");
return;
}
$total_mb = round($stats['total_bytes'] / 1024 / 1024, 2);
$quota_mb = round($stats['max_bytes'] / 1024 / 1024, 2);
$this->info("{$title}:");
$this->info(" Files: " . number_format($stats['file_count']));
$this->info(" Total Size: {$total_mb} MB / {$quota_mb} MB ({$stats['usage_percent']}%)");
if ($stats['oldest'] !== null) {
$oldest_age = $this->format_age($stats['oldest']);
$newest_age = $this->format_age($stats['newest']);
$this->info(" Oldest: {$oldest_age}");
$this->info(" Newest: {$newest_age}");
}
}
/**
* Display breakdown of preset thumbnails by preset name
*
* @param array $breakdown Breakdown from service
* @return void
*/
protected function display_preset_breakdown($breakdown)
{
$this->info('Breakdown by preset:');
foreach ($breakdown as $preset_name => $stats) {
if ($stats['file_count'] > 0) {
$mb = round($stats['total_bytes'] / 1024 / 1024, 2);
$this->info(" {$preset_name}: {$stats['file_count']} files, {$mb} MB");
}
}
}
/**
* Format timestamp as human-readable age
*
* @param int $timestamp Unix timestamp
* @return string Human-readable age
*/
protected function format_age($timestamp)
{
$age = time() - $timestamp;
if ($age < 60) {
return "{$age} seconds ago";
} elseif ($age < 3600) {
$minutes = floor($age / 60);
return "{$minutes} minute" . ($minutes > 1 ? 's' : '') . " ago";
} elseif ($age < 86400) {
$hours = floor($age / 3600);
return "{$hours} hour" . ($hours > 1 ? 's' : '') . " ago";
} else {
$days = floor($age / 86400);
return "{$days} day" . ($days > 1 ? 's' : '') . " ago";
}
}
}

View File

@@ -343,6 +343,12 @@ class Ajax
$json_response['console_debug'] = $console_messages;
}
// Add flash_alerts if any pending for current session
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
if (!empty($flash_messages)) {
$json_response['flash_alerts'] = $flash_messages;
}
return response()->json($json_response);
}
@@ -436,6 +442,12 @@ class Ajax
$json_response['console_debug'] = $console_messages;
}
// Add flash_alerts if any pending for current session
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
if (!empty($flash_messages)) {
$json_response['flash_alerts'] = $flash_messages;
}
return $json_response;
}

View File

@@ -2031,7 +2031,18 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
// Get file data from manifest
$file_data = $manifest_files[$relative] ?? null;
// If we have class information from manifest, use it
// All JavaScript files MUST be in manifest
if ($file_data === null) {
throw new \RuntimeException(
"JavaScript file in bundle but not in manifest (this should never happen):\n" .
"File: {$file}\n" .
"Relative: {$relative}\n" .
"Bundle: {$this->bundle_name}\n" .
"Manifest has " . count($manifest_files) . " files total"
);
}
// If file has a class, add to class definitions
if (!empty($file_data['class'])) {
$class_name = $file_data['class'];
$extends_class = $file_data['extends'] ?? null;
@@ -2046,19 +2057,8 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
'extends' => $extends_class,
'decorators' => $file_data['method_decorators'] ?? null,
];
} elseif (file_exists($file)) {
// Parse the file directly for classes
$content = file_get_contents($file);
$classes = $this->_parse_javascript_classes($content);
foreach ($classes as $class_info) {
$class_definitions[$class_info['name']] = [
'name' => $class_info['name'],
'extends' => $class_info['extends'],
'decorators' => null, // No decorator info when parsing directly
];
}
}
// Otherwise, it's a standalone function file - no manifest registration needed
}
// If no classes found, return null
@@ -2105,27 +2105,6 @@ implode("\n", array_map(fn ($f) => ' - ' . str_replace(base_path() . '/', '',
return $this->_write_temp_file($js_code, 'js');
}
/**
* Parse JavaScript content to extract class definitions
*/
protected function _parse_javascript_classes(string $content): array
{
$classes = [];
// Match ES6 class declarations with optional extends
$pattern = '/(?:^|\s)class\s+([A-Z]\w*)(?:\s+extends\s+([A-Z]\w*))?/m';
if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$classes[] = [
'name' => $match[1],
'extends' => isset($match[2]) ? $match[2] : null,
];
}
}
return $classes;
}
/**
* Create JavaScript runner for automatic class initialization

View File

@@ -24,6 +24,8 @@ class Core_Bundle extends Rsx_Bundle_Abstract
__DIR__,
'app/RSpade/Core/Js',
'app/RSpade/Core/Data',
'app/RSpade/Core/SPA',
'app/RSpade/Lib',
],
];
}

View File

@@ -255,6 +255,14 @@ abstract class Rsx_Bundle_Abstract
// Add build_key (always included in both dev and production)
$rsxapp_data['build_key'] = $manifest_hash;
// Add session_hash if session exists (hashed for scoping, non-reversible)
$session_token = $_COOKIE['rsx'] ?? null;
if ($session_token) {
$rsxapp_data['session_hash'] = hash_hmac('sha256', $session_token, config('app.key'));
} else {
$rsxapp_data['session_hash'] = null;
}
// Add bundle data if present
if (!empty($compiled['config'])) {
$rsxapp_data = array_merge($rsxapp_data, $compiled['bundle_data'] ?? []);
@@ -265,6 +273,7 @@ abstract class Rsx_Bundle_Abstract
$rsxapp_data['current_controller'] = \App\RSpade\Core\Rsx::get_current_controller();
$rsxapp_data['current_action'] = \App\RSpade\Core\Rsx::get_current_action();
$rsxapp_data['is_auth'] = Session::is_logged_in();
$rsxapp_data['is_spa'] = \App\RSpade\Core\Rsx::is_spa();
$rsxapp_data['ajax_disable_batching'] = config('rsx.development.ajax_disable_batching', false);
// Add current params (always set to reduce state variations)
@@ -286,6 +295,12 @@ abstract class Rsx_Bundle_Abstract
$rsxapp_data['page_data'] = \App\RSpade\Core\View\PageData::get();
}
// Add flash_alerts if any pending for current session
$flash_messages = \App\RSpade\Lib\Flash\Flash_Alert::get_pending_messages();
if (!empty($flash_messages)) {
$rsxapp_data['flash_alerts'] = $flash_messages;
}
// Add console_debug config in non-production mode
if (!app()->environment('production')) {
$console_debug_config = config('rsx.console_debug', []);
@@ -641,8 +656,8 @@ abstract class Rsx_Bundle_Abstract
}
}
// Throw error if not covered
if (!$is_covered) {
// Throw error if not covered (skip for framework views in app/RSpade/)
if (!$is_covered && !str_starts_with($view_path, 'app/RSpade/')) {
// Bundle doesn't include the view directory
BundleErrors::view_not_covered($view_path, $view_dir, $bundle_class, $include_paths);
}

View File

@@ -200,8 +200,12 @@ class SqlQueryTransformer
*/
private static function __validate_forbidden_types(string $query): void
{
// Strip comments and quoted strings before validation to avoid false positives
// (e.g., "TIME" in a COMMENT won't trigger the TIME type check)
$query_without_strings = self::__strip_comments_and_strings($query);
// Check for ENUM
if (preg_match('/\bENUM\s*\(/i', $query)) {
if (preg_match('/\bENUM\s*\(/i', $query_without_strings)) {
throw new \RuntimeException(
"ENUM column type is forbidden in RSpade. Use VARCHAR with validation instead.\n" .
"ENUMs cannot be modified without table locks and cause deployment issues.\n" .
@@ -210,7 +214,7 @@ class SqlQueryTransformer
}
// Check for SET
if (preg_match('/\bSET\s*\(/i', $query)) {
if (preg_match('/\bSET\s*\(/i', $query_without_strings)) {
throw new \RuntimeException(
"SET column type is forbidden in RSpade. Use JSON or a separate table instead.\n" .
"Query: " . substr($query, 0, 200)
@@ -218,7 +222,7 @@ class SqlQueryTransformer
}
// Check for YEAR
if (preg_match('/\bYEAR\b/i', $query)) {
if (preg_match('/\bYEAR\b/i', $query_without_strings)) {
throw new \RuntimeException(
"YEAR column type is forbidden in RSpade. Use INT or DATE instead.\n" .
"Query: " . substr($query, 0, 200)
@@ -226,14 +230,102 @@ class SqlQueryTransformer
}
// Check for TIME (but allow DATETIME and TIMESTAMP)
if (preg_match('/\bTIME\b(?!STAMP)/i', $query)) {
throw new \RuntimeException(
"TIME column type is forbidden in RSpade. Use DATETIME instead.\n" .
"Query: " . substr($query, 0, 200)
);
if (preg_match('/\bTIME\b(?!STAMP)/i', $query_without_strings)) {
// Additional check: make sure it's not part of DATETIME
if (!preg_match('/\bDATETIME\b/i', $query_without_strings) ||
preg_match('/[,\s]\s*TIME\s+/i', $query_without_strings)) {
throw new \RuntimeException(
"TIME column type is forbidden in RSpade. Use DATETIME instead.\n" .
"Query: " . substr($query, 0, 200)
);
}
}
}
/**
* Strip comments and quoted strings from SQL query
*
* Removes:
* - Single-quoted strings
* - Double-quoted strings
* - SQL comments (both single-line and multi-line)
*
* This prevents false positives in validation (e.g., 'TIME' in a comment)
*
* @param string $query The SQL query
* @return string Query with comments and strings removed
*/
private static function __strip_comments_and_strings(string $query): string
{
$result = '';
$len = strlen($query);
$i = 0;
while ($i < $len) {
// Check for single-line comment --
if ($i < $len - 1 && $query[$i] === '-' && $query[$i + 1] === '-') {
// Skip until newline
while ($i < $len && $query[$i] !== "\n") {
$i++;
}
$i++; // Skip the newline
continue;
}
// Check for multi-line comment /* */
if ($i < $len - 1 && $query[$i] === '/' && $query[$i + 1] === '*') {
// Skip until */
$i += 2;
while ($i < $len - 1 && !($query[$i] === '*' && $query[$i + 1] === '/')) {
$i++;
}
$i += 2; // Skip the */
continue;
}
// Check for single-quoted string
if ($query[$i] === "'") {
$i++; // Skip opening quote
while ($i < $len) {
if ($query[$i] === "'") {
// Check for escaped quote ''
if ($i + 1 < $len && $query[$i + 1] === "'") {
$i += 2; // Skip escaped quote
} else {
$i++; // Skip closing quote
break;
}
} else {
$i++;
}
}
continue;
}
// Check for double-quoted string
if ($query[$i] === '"') {
$i++; // Skip opening quote
while ($i < $len) {
if ($query[$i] === '"') {
$i++; // Skip closing quote
break;
} else if ($query[$i] === '\\' && $i + 1 < $len) {
$i += 2; // Skip escaped character
} else {
$i++;
}
}
continue;
}
// Regular character - keep it
$result .= $query[$i];
$i++;
}
return $result;
}
/**
* Validate MySQL syntax patterns that commonly cause issues
*

View File

@@ -122,7 +122,7 @@ class Dispatcher
console_debug('DISPATCH', 'Matched default route pattern:', $controller_name, '::', $action_name);
// Try to find the controller using manifest
// First try to find as PHP controller
try {
$metadata = Manifest::php_get_metadata_by_class($controller_name);
$controller_fqcn = $metadata['fqcn'];
@@ -173,7 +173,56 @@ class Dispatcher
return redirect($proper_url, 302);
}
} catch (\RuntimeException $e) {
console_debug('DISPATCH', 'Controller not found in manifest:', $controller_name);
console_debug('DISPATCH', 'Not a PHP controller, checking if SPA action:', $controller_name);
// Not found as PHP controller - check if it's a SPA action
try {
$is_spa_action = Manifest::js_is_subclass_of($controller_name, 'Spa_Action');
if ($is_spa_action) {
console_debug('DISPATCH', 'Found SPA action class:', $controller_name);
// Get the file path for this JS class
$file_path = Manifest::js_find_class($controller_name);
// Get file metadata which contains decorator information
$file_data = Manifest::get_file($file_path);
if (!$file_data) {
console_debug('DISPATCH', 'SPA action metadata not found:', $controller_name);
return null;
}
// Extract route pattern from @route() decorator
// Format: [[0 => 'route', 1 => ['/contacts']], ...]
$route_pattern = null;
if (isset($file_data['decorators']) && is_array($file_data['decorators'])) {
foreach ($file_data['decorators'] as $decorator) {
if (isset($decorator[0]) && $decorator[0] === 'route') {
if (isset($decorator[1][0])) {
$route_pattern = $decorator[1][0];
break;
}
}
}
}
if ($route_pattern) {
// Generate proper URL for the SPA action
$params = array_merge($extra_params, $request->query->all());
$proper_url = Rsx::Route($controller_name, $action_name, $params);
console_debug('DISPATCH', 'Redirecting to SPA action route:', $proper_url);
return redirect($proper_url, 302);
} else {
console_debug('DISPATCH', 'SPA action missing @route() decorator:', $controller_name);
}
}
} catch (\RuntimeException $spa_e) {
console_debug('DISPATCH', 'Not a SPA action either:', $controller_name);
}
return null;
}
}
@@ -246,7 +295,8 @@ class Dispatcher
Debugger::console_debug('DISPATCH', 'Matched route to ' . $handler_class . '::' . $handler_method . ' params: ' . json_encode($params));
// Set current controller and action in Rsx for tracking
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params);
$route_type = $route_match['type'] ?? 'standard';
\App\RSpade\Core\Rsx::_set_current_controller_action($handler_class, $handler_method, $params, $route_type);
// Load and validate handler class
static::__load_handler_class($handler_class);
@@ -339,93 +389,78 @@ class Dispatcher
if (empty($routes)) {
\Log::debug('Manifest::get_routes() returned empty array');
console_debug('DISPATCH', 'Warning: got 0 routes from Manifest::get_routes()');
} else {
\Log::debug('Manifest has ' . count($routes) . ' route types');
// Log details for debugging but don't output to console
foreach ($routes as $type => $type_routes) {
\Log::debug("Route type '$type' has " . count($type_routes) . ' patterns');
// Show first few patterns for debugging in logs only
$patterns = array_slice(array_keys($type_routes), 0, 5);
\Log::debug(' First patterns: ' . implode(', ', $patterns));
}
return null;
}
// Sort handler types by priority
$sorted_types = array_keys(static::$handler_priorities);
usort($sorted_types, function ($a, $b) {
return static::$handler_priorities[$a] - static::$handler_priorities[$b];
});
\Log::debug('Manifest has ' . count($routes) . ' routes');
// Collect all matching routes
$matches = [];
// Get all patterns and sort by priority
$patterns = array_keys($routes);
$patterns = RouteResolver::sort_by_priority($patterns);
// Try each handler type in priority order
foreach ($sorted_types as $type) {
if (!isset($routes[$type])) {
// Try to match each pattern
foreach ($patterns as $pattern) {
$route = $routes[$pattern];
// Check if HTTP method is supported
if (!in_array($method, $route['methods'])) {
continue;
}
$type_routes = $routes[$type];
// Try to match the URL
$params = RouteResolver::match_with_query($url, $pattern);
// Get all patterns for this type
$patterns = array_keys($type_routes);
if ($params !== false) {
// Found a match - verify the method has the required attribute
$class_fqcn = $route['class'];
$method_name = $route['method'];
// Sort patterns by priority
$patterns = RouteResolver::sort_by_priority($patterns);
// Get method metadata from manifest
$class_metadata = Manifest::php_get_metadata_by_fqcn($class_fqcn);
$method_metadata = $class_metadata['public_static_methods'][$method_name] ?? null;
// Try to match each pattern
foreach ($patterns as $pattern) {
$route_info = $type_routes[$pattern];
if (!$method_metadata) {
throw new \RuntimeException(
"Route method not found in manifest: {$class_fqcn}::{$method_name}\n" .
"Pattern: {$pattern}"
);
}
// Check if method is supported
if (isset($route_info[$method])) {
// Try to match the URL
$params = RouteResolver::match_with_query($url, $pattern);
// Check for Route or SPA attribute
$attributes = $method_metadata['attributes'] ?? [];
$has_route = false;
if ($params !== false) {
// Handle new structure where each method can have multiple handlers
$handlers = $route_info[$method];
// If it's not an array of handlers, convert it (backwards compatibility)
if (!isset($handlers[0])) {
$handlers = [$handlers];
}
// Add all matching handlers
foreach ($handlers as $handler) {
$matches[] = [
'type' => $type,
'pattern' => $pattern,
'class' => $handler['class'],
'method' => $handler['method'],
'params' => $params,
'file' => $handler['file'] ?? null,
'require' => $handler['require'] ?? [],
];
}
foreach ($attributes as $attr_name => $attr_instances) {
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route' ||
str_ends_with($attr_name, '\\SPA') || $attr_name === 'SPA') {
$has_route = true;
break;
}
}
}
}
// Check for duplicate routes
if (count($matches) > 1) {
$error_msg = "Multiple routes match the request '{$method} {$url}':\n\n";
foreach ($matches as $match) {
$error_msg .= " - Pattern: {$match['pattern']}\n";
$error_msg .= " Class: {$match['class']}::{$match['method']}\n";
if (!empty($match['file'])) {
$error_msg .= " File: {$match['file']}\n";
if (!$has_route) {
throw new \RuntimeException(
"Route method {$class_fqcn}::{$method_name} is missing required #[Route] or #[SPA] attribute.\n" .
"Pattern: {$pattern}\n" .
"File: {$route['file']}"
);
}
$error_msg .= " Type: {$match['type']}\n\n";
}
$error_msg .= 'Routes must be unique. Please remove duplicate route definitions.';
throw new RuntimeException($error_msg);
// Return route with params
return [
'type' => $route['type'],
'pattern' => $pattern,
'class' => $route['class'],
'method' => $route['method'],
'params' => $params,
'file' => $route['file'] ?? null,
'require' => $route['require'] ?? [],
];
}
}
// Return the single match or null
return $matches[0] ?? null;
// No match found
return null;
}
/**
@@ -572,8 +607,9 @@ class Dispatcher
throw new Exception("Method not public: {$class_name}::{$method_name}");
}
// Set current controller and action for tracking
Rsx::_set_current_controller_action($class_name, $method_name, $params);
// NOTE: Do NOT call _set_current_controller_action here - it's already been set
// earlier in the dispatch flow with the correct route type. Calling it again
// would overwrite the route type with null.
// Check if this is a controller (all methods are static)
if (static::__is_controller($class_name)) {
@@ -1096,7 +1132,11 @@ class Dispatcher
"Auth redirect_to must be array ['Controller', 'action'] on {$handler_class}::{$handler_method}"
);
}
$url = Rsx::Route($redirect_to[0], $redirect_to[1] ?? 'index');
$action = $redirect_to[0];
if (isset($redirect_to[1]) && $redirect_to[1] !== 'index') {
$action .= '::' . $redirect_to[1];
}
$url = Rsx::Route($action);
if ($message) {
Rsx::flash_error($message);
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\RSpade\Core\Dispatch;
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
/**
* Support module for building routes index from #[Route] attributes
* This runs after the primary manifest is built to create routes index
*/
class Route_ManifestSupport extends ManifestSupport_Abstract
{
/**
* Get the name of this support module
*
* @return string
*/
public static function get_name(): string
{
return 'Routes';
}
/**
* Process the manifest and build routes index
*
* @param array &$manifest_data Reference to the manifest data array
* @return void
*/
public static function process(array &$manifest_data): void
{
// Initialize routes key
if (!isset($manifest_data['data']['routes'])) {
$manifest_data['data']['routes'] = [];
}
// Look for Route attributes - must check all namespaces since Route is not a real class
// PHP attributes without an import will use the current namespace
$files = $manifest_data['data']['files'];
$route_classes = [];
foreach ($files as $file => $metadata) {
// Check public static method attributes for any attribute ending with 'Route'
if (isset($metadata['public_static_methods'])) {
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
if (isset($method_data['attributes'])) {
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
// Check if this is a Route attribute (ends with \Route or is just Route)
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route') {
$route_classes[] = [
'file' => $file,
'class' => $metadata['class'] ?? null,
'fqcn' => $metadata['fqcn'] ?? null,
'method' => $method_name,
'type' => 'method',
'instances' => $attr_instances,
];
}
}
}
}
}
}
foreach ($route_classes as $item) {
if ($item['type'] === 'method') {
foreach ($item['instances'] as $route_args) {
$pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
$methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']);
$name = $route_args[2] ?? ($route_args['name'] ?? null);
if ($pattern) {
// Ensure pattern starts with /
if ($pattern[0] !== '/') {
$pattern = '/' . $pattern;
}
// Type is always 'standard' for routes with #[Route] attribute
$type = 'standard';
// Extract Auth attributes for this method from the file metadata
$require_attrs = [];
$file_metadata = $files[$item['file']] ?? null;
if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'])) {
$require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'];
}
// Check for duplicate route definition (pattern must be unique across all route types)
if (isset($manifest_data['data']['routes'][$pattern])) {
$existing = $manifest_data['data']['routes'][$pattern];
$existing_type = $existing['type'];
$existing_location = $existing_type === 'spa'
? "SPA action {$existing['js_action_class']} in {$existing['file']}"
: "{$existing['class']}::{$existing['method']} in {$existing['file']}";
throw new \RuntimeException(
"Duplicate route definition: {$pattern}\n" .
" Already defined: {$existing_location}\n" .
" Conflicting: {$item['fqcn']}::{$item['method']} in {$item['file']}"
);
}
// Store route with flat structure
$manifest_data['data']['routes'][$pattern] = [
'methods' => array_map('strtoupper', (array) $methods),
'type' => $type,
'class' => $item['fqcn'] ?? $item['class'],
'method' => $item['method'],
'name' => $name,
'file' => $item['file'],
'require' => $require_attrs,
];
}
}
}
}
// Sort routes alphabetically by path to ensure deterministic behavior and prevent race condition bugs
ksort($manifest_data['data']['routes']);
}
}

229
app/RSpade/Core/Files/CLAUDE.md Executable file
View File

@@ -0,0 +1,229 @@
# File Attachments System
## Overview
The RSpade file attachment system provides secure, session-based file uploads with automatic thumbnail generation and polymorphic model associations.
## Upload Flow
**Security Model**: Files upload UNATTACHED → validate → assign via API
1. User uploads file to `/_upload` endpoint
2. File saved with `session_id`, no model association
3. Returns unique `key` to frontend
4. Frontend calls API endpoint with key
5. Backend validates ownership and assigns to model
## Security Implementation
**Session-based validation** prevents cross-user file assignment:
- Files get `session_id` on upload
- `can_user_assign_this_file()` validates:
- File not already assigned
- Same site_id as user's session
- Same session_id (prevents cross-user assignment)
- User-provided `fileable_*` params ignored during upload
## Attachment API
### File_Attachment_Model Methods
```php
// Find attachment by key
$attachment = File_Attachment_Model::find_by_key($key);
// Validate user can assign
if ($attachment->can_user_assign_this_file()) {
// Single file attachment (replaces existing)
$attachment->attach_to($user, 'profile_photo');
// Multiple file attachment (adds to collection)
$attachment->add_to($project, 'documents');
}
// Remove assignment
$attachment->detach();
// Check assignment status
if ($attachment->is_attached()) {
// File is assigned to a model
}
```
### Model Helper Methods
All models extending `Rsx_Model_Abstract` have attachment helpers:
```php
// Get single attachment
$photo = $user->get_attachment('profile_photo');
// Get multiple attachments
$docs = $project->get_attachments('documents');
// Count attachments
$count = $project->count_attachments('documents');
// Check if has attachments
if ($user->has_attachment('profile_photo')) {
// User has profile photo
}
```
## Display URLs
```php
// Thumbnail with specific dimensions
$photo->get_thumbnail_url('cover', 128, 128);
// Full file URL
$photo->get_url();
// Force download URL
$photo->get_download_url();
// Get file metadata
$size = $photo->file_size;
$mime = $photo->mime_type;
$name = $photo->original_filename;
```
## Endpoints
- `/_upload` - File upload endpoint
- `/_download/:key` - Force download file
- `/_thumbnail/:key/:type/:w/:h` - Generated thumbnail
- `/_file/:key` - Direct file access
## Thumbnail System
Thumbnails are generated on-demand and cached:
**Preset types** (defined in config):
- `cover` - Cover image aspect ratio
- `square` - 1:1 aspect ratio
- `landscape` - 16:9 aspect ratio
**Dynamic thumbnails**:
- Limited to `max_dynamic_size` (default 2000px)
- Cached for performance
- Automatic cleanup via scheduled task
## Controller Implementation Pattern
```php
#[Ajax_Endpoint]
public static function save_with_photo(Request $request, array $params = [])
{
// Validate required fields
if (empty($params['name'])) {
return response_form_error('Validation failed', [
'name' => 'Name is required'
]);
}
// Save model
$user = new User_Model();
$user->name = $params['name'];
$user->save();
// Attach photo if provided
if (!empty($params['photo_key'])) {
$photo = File_Attachment_Model::find_by_key($params['photo_key']);
if (!$photo || !$photo->can_user_assign_this_file()) {
return response_form_error('Invalid file', [
'photo' => 'File not found or access denied'
]);
}
$photo->attach_to($user, 'profile_photo');
}
return ['user_id' => $user->id];
}
```
## Frontend Upload Component
```javascript
// Using Rsx_File_Upload component
<Rsx_File_Upload
$id="photo_upload"
$accept="image/*"
$max_size="5242880"
/>
// Get uploaded file key
const key = this.$id('photo_upload').component().get_file_key();
// Submit with form
const data = {
name: this.$id('name').val(),
photo_key: key
};
await Controller.save_with_photo(data);
```
## Database Schema
```sql
file_attachments
├── id (bigint)
├── key (varchar 64, unique)
├── site_id (bigint)
├── session_id (bigint)
├── fileable_type (varchar 255, nullable)
├── fileable_id (bigint, nullable)
├── fileable_key (varchar 255, nullable)
├── storage_path (varchar 500)
├── original_filename (varchar 500)
├── mime_type (varchar 255)
├── file_size (bigint)
├── metadata (json)
└── timestamps
```
## Configuration
In `/system/config/rsx.php`:
```php
'attachments' => [
'upload_dir' => storage_path('rsx-attachments'),
'max_upload_size' => 10 * 1024 * 1024, // 10MB
'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx'],
],
'thumbnails' => [
'presets' => [
'cover' => ['width' => 800, 'height' => 600],
'square' => ['width' => 300, 'height' => 300],
],
'max_dynamic_size' => 2000,
'quotas' => [
'preset_max_bytes' => 500 * 1024 * 1024, // 500MB
'dynamic_max_bytes' => 100 * 1024 * 1024, // 100MB
],
],
```
## Security Considerations
1. **Never trust client-provided fileable_* params** during upload
2. **Always validate ownership** before assignment
3. **Use polymorphic associations** for flexibility
4. **Implement access control** in download endpoints
5. **Sanitize filenames** to prevent directory traversal
6. **Validate MIME types** server-side
7. **Set appropriate upload size limits**
8. **Use scheduled cleanup** for orphaned files
## Scheduled Cleanup
Orphaned files (uploaded but never assigned) are cleaned automatically:
- Files older than 24 hours without assignment
- Runs daily via scheduled task
- Preserves actively used files
See also: `php artisan rsx:man file_upload`

View File

@@ -418,7 +418,146 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
// ============================================================================================
/**
* Generate thumbnail for image attachments
* Generate and serve thumbnail with caching
*
* Common logic for both preset and dynamic thumbnails.
*
* @param File_Attachment_Model $attachment
* @param string $type Thumbnail type: 'cover' or 'fit'
* @param int $width Width in pixels
* @param int|null $height Height in pixels
* @param string $cache_type Either 'preset' or 'dynamic'
* @param string $cache_filename Cache filename
* @return Response
*/
protected static function __generate_and_serve_thumbnail(
$attachment,
$type,
$width,
$height,
$cache_type,
$cache_filename
) {
// Get storage file
$storage = $attachment->file_storage;
if (!$storage || !file_exists($storage->get_full_path())) {
abort(404, 'File not found on disk');
}
// Build cache path
$cache_path = static::_get_cache_path($cache_type, $cache_filename);
// Check cache
if (file_exists($cache_path)) {
$response = static::_serve_cached_thumbnail($cache_path);
if ($response !== null) {
return $response;
}
// If null, file was deleted (race condition) - fall through to regeneration
}
// Generate thumbnail
if ($attachment->is_image()) {
$thumbnail_data = static::__generate_thumbnail(
$storage->get_full_path(),
$type,
$width,
$height
);
} else {
// Use icon-based thumbnail for non-images
$thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail(
$attachment->file_extension,
$width,
$height ?? $width
);
}
// Save to cache
static::_save_thumbnail_to_cache($cache_path, $thumbnail_data);
// Enforce quota only for dynamic thumbnails
if ($cache_type === 'dynamic') {
static::_enforce_dynamic_quota();
}
// Return thumbnail (serve from memory, don't re-read from disk)
return Response::make($thumbnail_data, 200, [
'Content-Type' => 'image/webp',
'Cache-Control' => 'public, max-age=31536000', // 1 year
]);
}
/**
* Generate preset thumbnail for image attachments
*
* Route: /_thumbnail/:key/preset/:preset_name
*
* Security: Checks file.thumbnail.authorize only
*
* @param string $key Attachment key
* @param string $preset_name Preset name from config
*/
#[Route('/_thumbnail/:key/preset/:preset_name', methods: ['GET'])]
#[Auth('Permission::anybody()')]
public static function thumbnail_preset(Request $request, array $params = [])
{
$key = $params['key'] ?? null;
$preset_name = $params['preset_name'] ?? null;
// Validate inputs
if (!$key || !$preset_name) {
abort(400, 'Invalid parameters');
}
// Look up preset definition
$presets = config('rsx.thumbnails.presets', []);
if (!isset($presets[$preset_name])) {
abort(404, "Thumbnail preset '{$preset_name}' not defined");
}
$preset = $presets[$preset_name];
$type = $preset['type'];
$width = $preset['width'];
$height = $preset['height'] ?? null;
// Find attachment
$attachment = File_Attachment_Model::where('key', $key)->first();
if (!$attachment) {
abort(404, 'File not found');
}
// Event: file.thumbnail.authorize (gate) - Check thumbnail access
$thumbnail_auth = Rsx::trigger_gate('file.thumbnail.authorize', [
'attachment' => $attachment,
'user' => Session::get_user(),
'request' => $request,
]);
if ($thumbnail_auth !== true) {
return $thumbnail_auth;
}
// Generate cache filename
$cache_filename = static::_get_cache_filename_preset(
$preset_name,
$attachment->file_storage->hash,
$attachment->file_extension
);
// Generate and serve
return static::__generate_and_serve_thumbnail(
$attachment,
$type,
$width,
$height,
'preset',
$cache_filename
);
}
/**
* Generate dynamic thumbnail for image attachments
*
* Route: /_thumbnail/:key/:type/:width/:height?
*
@@ -451,13 +590,14 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
$height = 10;
}
// Enforce maximum dimensions
if ($width > 256) {
abort(400, 'Width must be between 10 and 256');
// Enforce maximum dimensions (configurable, base resolution before 2x scaling)
$max_size = config('rsx.thumbnails.max_dynamic_size', 800);
if ($width > $max_size) {
abort(400, "Width must be between 10 and {$max_size}");
}
if ($height !== null && $height > 256) {
abort(400, 'Height must be between 10 and 256');
if ($height !== null && $height > $max_size) {
abort(400, "Height must be between 10 and {$max_size}");
}
// Find attachment
@@ -477,42 +617,33 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
return $thumbnail_auth;
}
// Get storage file
$storage = $attachment->file_storage;
if (!$storage || !file_exists($storage->get_full_path())) {
abort(404, 'File not found on disk');
}
// Generate cache filename
$cache_filename = static::_get_cache_filename_dynamic(
$type,
$width,
$height ?? $width,
$attachment->file_storage->hash,
$attachment->file_extension
);
// Try to generate thumbnail from actual file if possible
if ($attachment->is_image()) {
// Generate thumbnail from image
$thumbnail_data = static::__generate_thumbnail(
$storage->get_full_path(),
$type,
$width,
$height
);
} else {
// No source image or converter available - use icon-based thumbnail
// NOTE: Not caching here because caching will be implemented at the
// thumbnail storage level to allow deduplication across files of the same type
$thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail(
$attachment->file_extension,
$width,
$height ?? $width
);
}
// Return thumbnail
return Response::make($thumbnail_data, 200, [
'Content-Type' => 'image/webp',
'Cache-Control' => 'public, max-age=31536000', // 1 year
]);
// Generate and serve
return static::__generate_and_serve_thumbnail(
$attachment,
$type,
$width,
$height,
'dynamic',
$cache_filename
);
}
/**
* Generate thumbnail image using Imagick
*
* Applies 2x resolution scaling for HiDPI displays, but caps at 66% of source
* image dimensions to avoid excessive upscaling. Output aspect ratio always
* matches requested dimensions, but actual resolution may be lower.
*
* @param string $source_path Source file path
* @param string $type Thumbnail type: 'cover' or 'fit'
* @param int $width Target width
@@ -537,17 +668,37 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
$height = (int)round(($width / $original_width) * $original_height);
}
// Constrain to 256x256 max
if ($width > 256 || $height > 256) {
if ($width > $height) {
$height = (int)round(($height / $width) * 256);
$width = 256;
// Apply 2x scaling for HiDPI displays
$target_width = $width * 2;
$target_height = $height * 2;
// Calculate 66% threshold of source dimensions
$max_width = (int)round($original_width * 0.66);
$max_height = (int)round($original_height * 0.66);
// If target exceeds 66% of source on either dimension, cap at source dimensions
if ($target_width > $max_width || $target_height > $max_height) {
$target_width = $original_width;
$target_height = $original_height;
}
// Constrain to configured max (doubled for 2x scaling)
// Default: 800 base → 1600 max after 2x
$max_size = config('rsx.thumbnails.max_dynamic_size', 800) * 2;
if ($target_width > $max_size || $target_height > $max_size) {
if ($target_width > $target_height) {
$target_height = (int)round(($target_height / $target_width) * $max_size);
$target_width = $max_size;
} else {
$width = (int)round(($width / $height) * 256);
$height = 256;
$target_width = (int)round(($target_width / $target_height) * $max_size);
$target_height = $max_size;
}
}
// Use target dimensions for actual generation
$width = $target_width;
$height = $target_height;
if ($type === 'cover') {
// Cover: Fill area completely, crop excess
// Calculate aspect ratios
@@ -614,6 +765,175 @@ class File_Attachment_Controller extends Rsx_Controller_Abstract
return $thumbnail_data;
}
// ============================================================================================
// THUMBNAIL CACHING HELPERS
// ============================================================================================
/**
* Generate cache filename for preset thumbnail
*
* Internal helper - do not call directly from application code.
*
* Format: {preset_name}_{hash}_{ext}.webp
*
* @param string $preset_name Preset name from config
* @param string $hash File storage hash
* @param string $extension File extension (normalized)
* @return string Cache filename
*/
public static function _get_cache_filename_preset($preset_name, $hash, $extension)
{
return "{$preset_name}_{$hash}_{$extension}.webp";
}
/**
* Generate cache filename for dynamic thumbnail
*
* Internal helper - do not call directly from application code.
*
* Format: {type}_{width}x{height}_{hash}_{ext}.webp
*
* @param string $type Thumbnail type (cover or fit)
* @param int $width Width in pixels
* @param int $height Height in pixels
* @param string $hash File storage hash
* @param string $extension File extension (normalized)
* @return string Cache filename
*/
public static function _get_cache_filename_dynamic($type, $width, $height, $hash, $extension)
{
return "{$type}_{$width}x{$height}_{$hash}_{$extension}.webp";
}
/**
* Get full cache path for thumbnail
*
* Internal helper - do not call directly from application code.
*
* @param string $cache_type Either 'preset' or 'dynamic'
* @param string $filename Cache filename
* @return string Full filesystem path
*/
public static function _get_cache_path($cache_type, $filename)
{
return storage_path("rsx-thumbnails/{$cache_type}/{$filename}");
}
/**
* Serve cached thumbnail from disk with optional mtime touch
*
* Internal helper - do not call directly from application code.
*
* Handles race condition where file might be deleted between check and open.
* Returns null if file cannot be opened (caller should regenerate).
*
* @param string $cache_path Full path to cached file
* @return Response|null Response if successful, null if file unavailable
*/
protected static function _serve_cached_thumbnail($cache_path)
{
// Attempt to open file
$handle = @fopen($cache_path, 'r');
if ($handle === false) {
// File was deleted between exists check and open (race condition)
return null;
}
fclose($handle);
// Touch mtime if configured and old enough
$touch_enabled = config('rsx.thumbnails.touch_on_read', true);
$touch_interval = config('rsx.thumbnails.touch_interval', 600);
if ($touch_enabled && $touch_interval > 0) {
$mtime = filemtime($cache_path);
$age = time() - $mtime;
if ($age >= $touch_interval) {
touch($cache_path);
}
}
// Serve file
return Response::file($cache_path, [
'Content-Type' => 'image/webp',
'Cache-Control' => 'public, max-age=31536000', // 1 year
]);
}
/**
* Save generated thumbnail to cache
*
* Internal helper - do not call directly from application code.
*
* Creates directory if it doesn't exist.
*
* @param string $cache_path Full path where thumbnail should be saved
* @param string $thumbnail_data Binary WebP data
* @return void
*/
public static function _save_thumbnail_to_cache($cache_path, $thumbnail_data)
{
// Ensure directory exists
$dir = dirname($cache_path);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// Save thumbnail
file_put_contents($cache_path, $thumbnail_data);
}
/**
* Enforce quota for dynamic thumbnails
*
* Internal helper - do not call directly from application code.
*
* Scans dynamic thumbnail directory, calculates total size, and deletes
* oldest files (by mtime) until under quota limit.
*
* Called synchronously after creating each new dynamic thumbnail.
*
* @return void
*/
protected static function _enforce_dynamic_quota()
{
$max_bytes = config('rsx.thumbnails.quotas.dynamic_max_bytes');
$dir = storage_path('rsx-thumbnails/dynamic/');
// Ensure directory exists
if (!is_dir($dir)) {
return;
}
// Calculate current usage
$total_size = 0;
$files = [];
foreach (glob($dir . '*.webp') as $file) {
$size = filesize($file);
$mtime = filemtime($file);
$total_size += $size;
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
}
// Over quota? Delete oldest first
if ($total_size > $max_bytes) {
// Sort by mtime ascending (oldest first)
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
foreach ($files as $file) {
@unlink($file['path']);
$total_size -= $file['size'];
if ($total_size <= $max_bytes) {
break;
}
}
}
}
// ============================================================================================
// ICON UTILITIES
// ============================================================================================

View File

@@ -149,7 +149,7 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
*
* @var string
*/
protected $table = 'file_attachments';
protected $table = '_file_attachments';
/**
* Get the physical file storage record
@@ -246,7 +246,7 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
}
/**
* Get thumbnail URL for this file
* Get thumbnail URL for this file (dynamic thumbnails)
*
* @param string $type Thumbnail type: 'cover' or 'fit'
* @param int $width Thumbnail width in pixels
@@ -261,6 +261,27 @@ class File_Attachment_Model extends Rsx_Site_Model_Abstract
return url("/_thumbnail/{$this->key}/{$type}/{$width}/{$height}");
}
/**
* Get thumbnail URL for named preset
*
* Returns URL to preset thumbnail endpoint. Preset must be defined in
* config/rsx.php under 'thumbnails.presets'.
*
* @param string $preset_name Preset name from config (e.g., 'profile', 'gallery')
* @return string URL to thumbnail endpoint
* @throws Exception if preset not defined
*/
public function get_thumbnail_url_preset($preset_name)
{
$presets = config('rsx.thumbnails.presets', []);
if (!isset($presets[$preset_name])) {
throw new Exception("Thumbnail preset '{$preset_name}' not defined in config");
}
return url("/_thumbnail/{$this->key}/preset/{$preset_name}");
}
/**
* Get icon resource path for this file type
*

View File

@@ -40,7 +40,7 @@ class File_Storage_Model extends Rsx_Model_Abstract
*
* @var string
*/
protected $table = 'file_storage';
protected $table = '_file_storage';
/**
* The attributes that should be cast to native types

View File

@@ -1,161 +0,0 @@
<?php
namespace App\RSpade\Core\Files;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
use App\RSpade\Core\Files\File_Storage_Model;
/**
* File_Thumbnail_Model - Thumbnail cache with deduplication
*
* Represents generated thumbnails as derivative artifacts of source files.
* Thumbnails are deduplicated based on source file hash + parameters + mime type.
*
* Multiple File_Attachment_Model records sharing the same File_Storage_Model will
* share the same thumbnail if they request the same thumbnail parameters.
*/
/**
* _AUTO_GENERATED_ Database type hints - do not edit manually
* Generated on: 2025-11-04 07:18:11
* Table: file_thumbnails
*
* @property int $id
* @property int $source_storage_id
* @property int $thumbnail_storage_id
* @property string $params
* @property mixed $detected_mime_type
* @property string $created_at
* @property string $updated_at
* @property int $created_by
* @property int $updated_by
*
* @mixin \Eloquent
*/
class File_Thumbnail_Model extends Rsx_Model_Abstract
{
// Required static properties from parent abstract class
public static $enums = [];
public static $rel = [];
/**
* The table associated with the model
*
* @var string
*/
protected $table = 'file_thumbnails';
/**
* The attributes that should be cast to native types
*
* @var array
*/
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Column metadata for special handling
*
* @var array
*/
protected $columnMeta = [];
/**
* Get the source file storage record
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function source_storage()
{
return $this->belongsTo(File_Storage_Model::class, 'source_storage_id');
}
/**
* Get the thumbnail file storage record
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function thumbnail_storage()
{
return $this->belongsTo(File_Storage_Model::class, 'thumbnail_storage_id');
}
/**
* Get decoded params JSON
*
* @return array
*/
public function get_params()
{
return json_decode($this->params, true);
}
/**
* Calculate thumbnail cache key
*
* This key determines thumbnail deduplication. Thumbnails with the same
* source file, parameters, and detected mime type will share the same
* thumbnail storage.
*
* Formula: SHA-256(source_hash + params_json + detected_mime_type)
*
* @param string $source_hash The hash of the source File_Storage_Model
* @param array $params Thumbnail parameters (width, height, crop, format, quality)
* @param string $detected_mime Actual mime type detected from file content
* @return string SHA-256 hash for thumbnail lookup
*/
public static function calculate_thumbnail_key($source_hash, array $params, $detected_mime)
{
// Normalize params to ensure consistent key generation
ksort($params);
$params_json = json_encode($params);
// Combine all factors that determine thumbnail uniqueness
$key_string = $source_hash . $params_json . $detected_mime;
return hash('sha256', $key_string);
}
/**
* Find existing thumbnail or return null
*
* @param int $source_storage_id
* @param array $params
* @param string $detected_mime
* @return static|null
*/
public static function find_thumbnail($source_storage_id, array $params, $detected_mime)
{
$params_json = json_encode($params);
return static::where('source_storage_id', $source_storage_id)
->where('params', $params_json)
->where('detected_mime_type', $detected_mime)
->first();
}
/**
* Create or find a thumbnail record
*
* @param File_Storage_Model $source_storage
* @param File_Storage_Model $thumbnail_storage
* @param array $params
* @param string $detected_mime
* @return static
*/
public static function create_thumbnail($source_storage, $thumbnail_storage, array $params, $detected_mime)
{
$params_json = json_encode($params);
$thumbnail = new static();
$thumbnail->source_storage_id = $source_storage->id;
$thumbnail->thumbnail_storage_id = $thumbnail_storage->id;
$thumbnail->params = $params_json;
$thumbnail->detected_mime_type = $detected_mime;
$thumbnail->save();
return $thumbnail;
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace App\RSpade\Core\Files;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
use App\RSpade\Core\Task\Task_Instance;
/**
* File_Thumbnail_Service
*
* Business logic for thumbnail cache management including:
* - Quota enforcement via LRU eviction
* - Scheduled cleanup of preset thumbnails
* - Statistics and reporting
*
* Storage Structure:
* - storage/rsx-thumbnails/preset/ - Named preset thumbnails (100MB quota, scheduled cleanup)
* - storage/rsx-thumbnails/dynamic/ - Dynamic ad-hoc thumbnails (50MB quota, synchronous cleanup)
*
* Cleanup Strategy:
* - Preset: Runs every 30 minutes via scheduled task (this class)
* - Dynamic: Enforced synchronously in File_Attachment_Controller after each generation
* - Both: LRU eviction (oldest mtime deleted first)
*/
class File_Thumbnail_Service extends Rsx_Service_Abstract
{
/**
* Scheduled cleanup of preset thumbnail cache
*
* Runs every 30 minutes to enforce preset thumbnail quota.
* Deletes oldest files (by mtime) until under configured limit.
*
* @param Task_Instance $task Task instance for logging
* @param array $params Task parameters
*/
#[Task('Clean preset thumbnail cache (runs every 30 minutes)')]
#[Schedule('*/30 * * * *')]
public static function cleanup_preset_thumbnails(Task_Instance $task, array $params = [])
{
$max_bytes = config('rsx.thumbnails.quotas.preset_max_bytes');
$dir = storage_path('rsx-thumbnails/preset/');
if (!is_dir($dir)) {
// Directory doesn't exist yet - no cleanup needed
$task->info('Preset thumbnail directory does not exist yet');
return;
}
// Calculate current usage
$total_size = 0;
$files = [];
foreach (glob($dir . '*.webp') as $file) {
$size = filesize($file);
$mtime = filemtime($file);
$total_size += $size;
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
}
// Not over quota? Nothing to do
if ($total_size <= $max_bytes) {
return;
}
// Over quota - delete oldest files until under limit
// Sort by mtime ascending (oldest first)
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
$deleted_count = 0;
$freed_bytes = 0;
foreach ($files as $file) {
@unlink($file['path']);
$total_size -= $file['size'];
$deleted_count++;
$freed_bytes += $file['size'];
if ($total_size <= $max_bytes) {
break;
}
}
// Log cleanup statistics
$freed_mb = round($freed_bytes / 1024 / 1024, 2);
$task->info("Preset thumbnail cleanup: {$deleted_count} files deleted, {$freed_mb} MB freed");
}
/**
* Clean a thumbnail directory by enforcing quota
*
* Used by CLI commands for manual cleanup operations.
*
* @param string $type Either 'preset' or 'dynamic'
* @return array [files_deleted, bytes_freed]
*/
public static function clean_directory($type)
{
$quota_key = $type . '_max_bytes';
$max_bytes = config("rsx.thumbnails.quotas.{$quota_key}");
$dir = storage_path("rsx-thumbnails/{$type}/");
if (!is_dir($dir)) {
return [0, 0];
}
// Calculate current usage
$total_size = 0;
$files = [];
foreach (glob($dir . '*.webp') as $file) {
$size = filesize($file);
$mtime = filemtime($file);
$total_size += $size;
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
}
// Not over quota? Nothing to do
if ($total_size <= $max_bytes) {
return [0, 0];
}
// Over quota - delete oldest first (LRU eviction)
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
$deleted_count = 0;
$freed_bytes = 0;
foreach ($files as $file) {
@unlink($file['path']);
$total_size -= $file['size'];
$deleted_count++;
$freed_bytes += $file['size'];
if ($total_size <= $max_bytes) {
break;
}
}
return [$deleted_count, $freed_bytes];
}
/**
* Get statistics for thumbnail cache directories
*
* @return array Statistics for preset and dynamic directories
*/
public static function get_statistics()
{
$stats = [
'preset' => static::__get_directory_stats('preset'),
'dynamic' => static::__get_directory_stats('dynamic'),
];
// Add breakdown by preset name
$stats['preset_breakdown'] = static::__get_preset_breakdown();
return $stats;
}
/**
* Get statistics for a single directory
*
* @param string $type Either 'preset' or 'dynamic'
* @return array Statistics including file count, total size, oldest/newest
*/
protected static function __get_directory_stats($type)
{
$dir = storage_path("rsx-thumbnails/{$type}/");
$quota_key = $type . '_max_bytes';
$max_bytes = config("rsx.thumbnails.quotas.{$quota_key}");
if (!is_dir($dir)) {
return [
'exists' => false,
'file_count' => 0,
'total_bytes' => 0,
'max_bytes' => $max_bytes,
'usage_percent' => 0,
'oldest' => null,
'newest' => null,
];
}
$files = glob($dir . '*.webp');
$file_count = count($files);
$total_bytes = 0;
$oldest = null;
$newest = null;
foreach ($files as $file) {
$total_bytes += filesize($file);
$mtime = filemtime($file);
if ($oldest === null || $mtime < $oldest) {
$oldest = $mtime;
}
if ($newest === null || $mtime > $newest) {
$newest = $mtime;
}
}
return [
'exists' => true,
'file_count' => $file_count,
'total_bytes' => $total_bytes,
'max_bytes' => $max_bytes,
'usage_percent' => $max_bytes > 0 ? round(($total_bytes / $max_bytes) * 100, 1) : 0,
'oldest' => $oldest,
'newest' => $newest,
];
}
/**
* Get breakdown of preset thumbnails by preset name
*
* @return array Breakdown by preset name
*/
protected static function __get_preset_breakdown()
{
$dir = storage_path('rsx-thumbnails/preset/');
if (!is_dir($dir)) {
return [];
}
$presets = config('rsx.thumbnails.presets', []);
$breakdown = [];
// Initialize breakdown for each preset
foreach (array_keys($presets) as $preset_name) {
$breakdown[$preset_name] = [
'file_count' => 0,
'total_bytes' => 0,
];
}
// Count files for each preset
foreach (glob($dir . '*.webp') as $file) {
$filename = basename($file);
// Extract preset name from filename (format: presetname_hash_ext.webp)
if (preg_match('/^([^_]+)_/', $filename, $matches)) {
$preset_name = $matches[1];
if (isset($breakdown[$preset_name])) {
$breakdown[$preset_name]['file_count']++;
$breakdown[$preset_name]['total_bytes'] += filesize($file);
}
}
}
return $breakdown;
}
}

View File

@@ -0,0 +1,364 @@
# Thumbnail Caching System - Developer Documentation
## Overview
The RSX thumbnail caching system provides a two-tier architecture designed to prevent cache pollution while maintaining flexibility. This document explains the implementation details for framework developers.
## Architecture
### Two-Tier System
**Preset Thumbnails** (`storage/rsx-thumbnails/preset/`)
- Developer-defined named sizes in config
- Quota enforced via scheduled task (`rsx:thumbnails:clean --preset`)
- Used for regular application features
- Protected from abuse/spam
**Dynamic Thumbnails** (`storage/rsx-thumbnails/dynamic/`)
- Ad-hoc sizes via URL parameters
- Quota enforced synchronously after each generation
- Used for edge cases and development
- LRU eviction prevents unbounded growth
### Design Philosophy
**Problem**: Traditional on-demand thumbnail generation allows users to request arbitrary sizes (e.g., by manipulating URL parameters), potentially generating thousands of cached thumbnails and consuming disk space.
**Solution**:
1. Define all application thumbnail sizes as named presets
2. Reference presets by name (`get_thumbnail_url_preset('profile')`)
3. Dynamic thumbnails still available but discouraged and quota-limited
**Benefits**:
- Prevents cache pollution from URL manipulation
- Centralizes thumbnail size definitions
- Enables cache warming via `rsx:thumbnails:generate`
- Predictable cache size and cleanup
## Storage Structure
### Filename Format
**Preset**: `{preset_name}_{storage_hash}_{extension}.webp`
- Example: `profile_abc123def456_jpg.webp`
**Dynamic**: `{type}_{width}x{height}_{storage_hash}_{extension}.webp`
- Example: `cover_200x200_abc123def456_jpg.webp`
### Why Include Extension?
Edge case: Identical file content uploaded with different extensions (e.g., `document.zip` and `document.docx`) should render different icon-based thumbnails. Including the extension in the filename ensures separate caches.
## Caching Flow
### Request Processing
**Route**: `/_thumbnail/:key/:type/:width/:height` (dynamic) or `/_thumbnail/:key/preset/:preset_name` (preset)
**Common Flow** (via `generate_and_serve_thumbnail()`):
1. Validate authorization (`file.thumbnail.authorize` event)
2. Generate cache filename
3. Check if cached file exists
4. If exists:
- Attempt to open (handles race condition)
- If successful: touch mtime (if enabled), serve from disk
- If failed: fall through to regeneration
5. If not exists:
- Generate thumbnail (Imagick for images, icon-based for others)
- Save to cache
- Enforce quota (dynamic only)
- Serve from memory (no re-read)
### Race Condition Handling
**Scenario**: Between `file_exists()` check and `fopen()`, file might be deleted by quota enforcement in another request.
**Solution**: `_serve_cached_thumbnail()` returns `null` if file cannot be opened, triggering immediate regeneration. User never sees an error.
```php
if (file_exists($cache_path)) {
$response = static::_serve_cached_thumbnail($cache_path);
if ($response !== null) {
return $response; // Success
}
// File deleted - fall through to regeneration
}
// Generate and serve
$thumbnail_data = static::__generate_thumbnail(...);
```
### LRU Tracking
**mtime Touching**:
- On cache hit, touch file's mtime if older than `touch_interval` (default 10 minutes)
- Prevents excessive filesystem writes while maintaining LRU accuracy
- Used by quota enforcement to delete oldest (least recently used) files first
**Configuration**:
```php
'touch_on_read' => true, // Enable/disable
'touch_interval' => 600, // Seconds (10 minutes)
```
## Quota Enforcement
### Dynamic Thumbnails (Synchronous)
Called after each new dynamic thumbnail generation:
```php
protected static function _enforce_dynamic_quota()
{
$max_bytes = config('rsx.thumbnails.quotas.dynamic_max_bytes');
$dir = storage_path('rsx-thumbnails/dynamic/');
// Scan directory
$total_size = 0;
$files = [];
foreach (glob($dir . '*.webp') as $file) {
$size = filesize($file);
$mtime = filemtime($file);
$total_size += $size;
$files[] = ['path' => $file, 'size' => $size, 'mtime' => $mtime];
}
// Over quota?
if ($total_size > $max_bytes) {
// Sort by mtime (oldest first)
usort($files, fn($a, $b) => $a['mtime'] <=> $b['mtime']);
// Delete oldest until under quota
foreach ($files as $file) {
unlink($file['path']);
$total_size -= $file['size'];
if ($total_size <= $max_bytes) break;
}
}
}
```
**Performance**: Glob + stat operations are fast for directories with <10,000 files. Dynamic quota prevents unbounded growth.
### Preset Thumbnails (Scheduled)
Not enforced synchronously. Run via scheduled task:
```bash
php artisan rsx:thumbnails:clean --preset
```
Same LRU algorithm as dynamic, but triggered externally.
## Helper Methods
### Internal Helpers (Underscore-Prefixed)
These are `public static` for artisan command access but prefixed with `_` to indicate they're internal:
**`_get_cache_filename_preset($preset_name, $hash, $extension)`**
- Generates: `{preset_name}_{hash}_{ext}.webp`
**`_get_cache_filename_dynamic($type, $width, $height, $hash, $extension)`**
- Generates: `{type}_{w}x{h}_{hash}_{ext}.webp`
**`_get_cache_path($cache_type, $filename)`**
- Returns: `storage_path("rsx-thumbnails/{$cache_type}/{$filename}")`
**`_save_thumbnail_to_cache($cache_path, $thumbnail_data)`**
- Writes WebP data to cache
- Creates directory if needed
**`_serve_cached_thumbnail($cache_path)`**
- Attempts to open file (race condition safe)
- Touches mtime if configured
- Returns Response or null
**`_enforce_dynamic_quota()`**
- Scans dynamic directory
- Deletes oldest files until under quota
### Why Public?
Artisan commands (`Thumbnails_Generate_Command`, etc.) need access to these helpers. Making them public with `_` prefix indicates "internal use only - don't call from application code."
## DRY Implementation
**Single Generation Function**:
```php
protected static function generate_and_serve_thumbnail(
$attachment,
$type,
$width,
$height,
$cache_type,
$cache_filename
) {
// Common logic for both preset and dynamic
// Check cache -> Generate if needed -> Save -> Enforce quota -> Serve
}
```
**Route Methods Are Thin Wrappers**:
```php
public static function thumbnail_preset(Request $request, array $params = [])
{
// Parse preset params
// Generate cache filename
// Call common function
return static::generate_and_serve_thumbnail(...);
}
public static function thumbnail(Request $request, array $params = [])
{
// Parse dynamic params
// Generate cache filename
// Call common function
return static::generate_and_serve_thumbnail(...);
}
```
This eliminates duplication while keeping route-specific validation separate.
## Artisan Commands
### Thumbnails_Clean_Command
Enforces quotas for preset and/or dynamic thumbnails.
**Implementation**:
- Scans directory, builds array of [path, size, mtime]
- Sorts by mtime ascending (oldest first)
- Deletes files until under quota
- Reports statistics
**Flags**: `--preset`, `--dynamic`, `--all` (default)
### Thumbnails_Generate_Command
Pre-generates preset thumbnails for attachments.
**Implementation**:
- Queries File_Attachment_Model (optionally filtered by key)
- For each attachment/preset combination:
- Generate cache filename
- Skip if already cached
- Generate thumbnail
- Save to cache
- No quota enforcement (manual command)
**Use Cases**:
- Cache warming after deployment
- Background generation for new uploads
- Regenerating after changing preset sizes
### Thumbnails_Stats_Command
Displays cache usage statistics.
**Implementation**:
- Scans both directories
- Calculates: file count, total size, quota percentage, oldest/newest mtime
- For presets: breaks down by preset name (extracted from filename)
## WebP Output
All thumbnails are WebP format for optimal size/quality:
```php
$image->setImageFormat('webp');
$image->setImageCompressionQuality(85);
$thumbnail_data = $image->getImageBlob();
```
**Why WebP?**
- Superior compression vs JPEG/PNG
- Supports transparency (for 'fit' thumbnails)
- Universal browser support (96%+ as of 2024)
## Non-Image Files
Icon-based thumbnails for PDFs, documents, etc.:
```php
if ($attachment->is_image()) {
$thumbnail_data = static::__generate_thumbnail(...);
} else {
$thumbnail_data = File_Attachment_Icons::render_icon_as_thumbnail(
$attachment->file_extension,
$width,
$height
);
}
```
Icons are rendered as WebP images for consistency.
## Security
**Authorization**: All thumbnail endpoints check `file.thumbnail.authorize` event gate. Implement handlers in `/rsx/handlers/` to control access.
**Dimension Limits**: Dynamic thumbnails enforce 10-256 pixel limits to prevent abuse. Preset thumbnails have no limits (developer-controlled).
**Extension Validation**: File extensions are normalized (lowercase, jpeg→jpg) before cache filename generation.
## Configuration
All configuration in `/system/config/rsx.php` under `thumbnails` key:
```php
'thumbnails' => [
'presets' => [
'profile' => ['type' => 'cover', 'width' => 200, 'height' => 200],
// ...
],
'quotas' => [
'preset_max_bytes' => 100 * 1024 * 1024, // 100MB
'dynamic_max_bytes' => 50 * 1024 * 1024, // 50MB
],
'touch_on_read' => env('THUMBNAILS_TOUCH_ON_READ', true),
'touch_interval' => env('THUMBNAILS_TOUCH_INTERVAL', 600),
],
```
## File_Thumbnail_Model Removal
**Previous Design**: Database table tracking thumbnails with source/thumbnail storage relationships.
**Why Removed**:
- Filesystem is sufficient for caching
- mtime provides LRU info
- No need for DB overhead
- Simpler, faster, less to maintain
**Migration**: `2025_11_16_093331_drop_file_thumbnails_table.php` drops the table.
## Testing Checklist
- [ ] Preset thumbnail generation and caching
- [ ] Dynamic thumbnail generation and caching
- [ ] Cache hits serve from disk
- [ ] Cache misses generate and cache
- [ ] Race condition handling (file deleted between check and open)
- [ ] Dynamic quota enforcement deletes oldest first
- [ ] mtime touching respects config and interval
- [ ] Preset/dynamic survive `rsx:clean`
- [ ] Same hash + different extension creates separate thumbnails
- [ ] Invalid preset name throws exception
- [ ] All generated thumbnails are WebP
- [ ] `rsx:thumbnails:clean` enforces quotas
- [ ] `rsx:thumbnails:generate` pre-generates correctly
- [ ] `rsx:thumbnails:stats` shows accurate data
- [ ] Icon-based thumbnails for non-images work
- [ ] Browser cache headers correct (1 year)
## Future Enhancements
Not in current implementation but planned:
- **Animated thumbnails**: Extract first frame from GIF/WebP/video
- **Video thumbnails**: Frame extraction via FFmpeg
- **PDF thumbnails**: First page via Imagick
- **CDN integration**: Upload thumbnails to CDN storage
- **Per-preset quotas**: Different limits for different presets
- **Background generation**: Queue-based thumbnail generation
- **Analytics**: DB-backed usage tracking (optional)

View File

@@ -186,6 +186,11 @@ class Ajax {
});
}
// Handle flash_alerts from server
if (response.flash_alerts && Array.isArray(response.flash_alerts)) {
Server_Side_Flash.process(response.flash_alerts);
}
// Check if the response was successful
if (response._success === true) {
// @JS-AJAX-02-EXCEPTION - Unwrap server responses with _ajax_return_value

View File

@@ -249,7 +249,7 @@ class Debugger {
Debugger._console_timer = null;
try {
return Ajax.call(Rsx.Route('Debugger_Controller', 'log_console_messages'), { messages: messages });
return Ajax.call(Rsx.Route('Debugger_Controller::log_console_messages'), { messages: messages });
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send console_debug messages to server:', error);
@@ -270,7 +270,7 @@ class Debugger {
Debugger._error_batch_count++;
try {
return Ajax.call(Rsx.Route('Debugger_Controller', 'log_browser_errors'), { errors: errors });
return Ajax.call(Rsx.Route('Debugger_Controller::log_browser_errors'), { errors: errors });
} catch (error) {
// Silently fail - don't create error loop
console.error('Failed to send browser errors to server:', error);

View File

@@ -37,7 +37,7 @@
* if (Rsx.is_dev()) { console.log('Development mode'); }
*
* // Route generation
* const url = Rsx.Route('Controller', 'action').url();
* const url = Rsx.Route('Controller::action').url();
*
* // Unique IDs
* const uniqueId = Rsx.uid(); // e.g., "rsx_1234567890_1"
@@ -134,70 +134,99 @@ class Rsx {
static _routes = {};
/**
* Define routes from bundled data
* Called by generated JavaScript in bundles
* Calculate scope key from current environment.
*
* Scope key is a hash key which includes the current value of the session, user, site, and build keys.
* Data hashed with this key will be scoped to the current logged in user, and will be invalidated if
* the user logs out or the application source code is updated / redeployed / etc.
*
* @returns {string}
* @private
*/
static _define_routes(routes) {
// Merge routes into the global route storage
for (const class_name in routes) {
if (!Rsx._routes[class_name]) {
Rsx._routes[class_name] = {};
}
for (const method_name in routes[class_name]) {
Rsx._routes[class_name][method_name] = routes[class_name][method_name];
}
static scope_key() {
const parts = [];
// Get session hash (hashed on server for non-reversible scoping)
if (window.rsxapp?.session_hash) {
parts.push(window.rsxapp.session_hash);
}
// Get user ID
if (window.rsxapp?.user?.id) {
parts.push(window.rsxapp.user.id);
}
// Get site ID
if (window.rsxapp?.site?.id) {
parts.push(window.rsxapp.site.id);
}
// Get build key
if (window.rsxapp?.build_key) {
parts.push(window.rsxapp.build_key);
}
return parts.join('_');
}
/**
* Generate URL for a controller route
* Generate URL for a controller route or SPA action
*
* This method generates URLs for controller actions by looking up route patterns
* and replacing parameters. It handles both regular routes and Ajax endpoints.
* This method generates URLs by looking up route patterns and replacing parameters.
* It handles controller routes, SPA action routes, and Ajax endpoints.
*
* If the route is not found in the route definitions, a default pattern is used:
* `/_/{controller}/{action}` with all parameters appended as query strings.
*
* Usage examples:
* ```javascript
* // Simple route without parameters (defaults to 'index' action)
* // Controller route (defaults to 'index' method)
* const url = Rsx.Route('Frontend_Index_Controller');
* // Returns: /dashboard
*
* // Route with explicit action
* const url = Rsx.Route('Frontend_Index_Controller', 'index');
* // Returns: /dashboard
*
* // Route with integer parameter (sets 'id')
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', 123);
* // Controller route with explicit method
* const url = Rsx.Route('Frontend_Client_View_Controller::view', 123);
* // Returns: /clients/view/123
*
* // SPA action route
* const url = Rsx.Route('Contacts_Index_Action');
* // Returns: /contacts
*
* // Route with integer parameter (sets 'id')
* const url = Rsx.Route('Contacts_View_Action', 123);
* // Returns: /contacts/123
*
* // Route with named parameters (object)
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {id: 'C001'});
* // Returns: /clients/view/C001
* const url = Rsx.Route('Contacts_View_Action', {id: 'C001'});
* // Returns: /contacts/C001
*
* // Route with required and query parameters
* const url = Rsx.Route('Frontend_Client_View_Controller', 'view', {
* const url = Rsx.Route('Contacts_View_Action', {
* id: 'C001',
* tab: 'history'
* });
* // Returns: /clients/view/C001?tab=history
*
* // Route not found - uses default pattern
* const url = Rsx.Route('Unimplemented_Controller', 'some_action', {foo: 'bar'});
* // Returns: /_/Unimplemented_Controller/some_action?foo=bar
* // Returns: /contacts/C001?tab=history
*
* // Placeholder route
* const url = Rsx.Route('Future_Controller', '#index');
* const url = Rsx.Route('Future_Controller::#index');
* // Returns: #
* ```
*
* @param {string} class_name The controller class name (e.g., 'User_Controller')
* @param {string} [action_name='index'] The action/method name (defaults to 'index'). Use '#action' for placeholders.
* @param {string} action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
* @param {number|Object} [params=null] Route parameters. Integer sets 'id', object provides named params.
* @returns {string} The generated URL
*/
static Route(class_name, action_name = 'index', params = null) {
static Route(action, params = null) {
// Parse action into class_name and action_name
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
let class_name, action_name;
if (action.includes('::')) {
[class_name, action_name] = action.split('::', 2);
} else {
class_name = action;
action_name = 'index';
}
// Normalize params to object
let params_obj = {};
if (typeof params === 'number') {
@@ -213,13 +242,19 @@ class Rsx {
return '#';
}
// Check if route exists in definitions
// Check if route exists in PHP controller definitions
let pattern;
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
pattern = Rsx._routes[class_name][action_name];
} else {
// Route not found - use default pattern /_/{controller}/{action}
pattern = `/_/${class_name}/${action_name}`;
// Not found in PHP routes - check if it's a SPA action
pattern = Rsx._try_spa_action_route(class_name);
if (!pattern) {
// Route not found - use default pattern /_/{controller}/{action}
// For SPA actions, action_name defaults to 'index'
pattern = `/_/${class_name}/${action_name}`;
}
}
// Generate URL from pattern
@@ -287,6 +322,60 @@ class Rsx {
return url;
}
/**
* Try to find a route pattern for a SPA action class
* Returns the route pattern or null if not found
*
* @param {string} class_name The action class name
* @returns {string|null} The route pattern or null
*/
static _try_spa_action_route(class_name) {
// Get all classes from manifest
const all_classes = Manifest.get_all_classes();
// Find the class by name
for (const class_info of all_classes) {
if (class_info.class_name === class_name) {
const class_object = class_info.class_object;
// Check if it's a SPA action (has Spa_Action in prototype chain)
if (typeof Spa_Action !== 'undefined' &&
class_object.prototype instanceof Spa_Action) {
// Get route patterns from decorator metadata
const routes = class_object._spa_routes || [];
if (routes.length > 0) {
// Return the first route pattern
return routes[0];
}
}
// Found the class but it's not a SPA action or has no routes
return null;
}
}
// Class not found
return null;
}
/**
* Define routes from bundled data
* Called by generated JavaScript in bundles
*/
static _define_routes(routes) {
// Merge routes into the global route storage
for (const class_name in routes) {
if (!Rsx._routes[class_name]) {
Rsx._routes[class_name] = {};
}
for (const method_name in routes[class_name]) {
Rsx._routes[class_name][method_name] = routes[class_name][method_name];
}
}
}
/**
* Internal: Call a specific method on all classes that have it
* Collects promises from return values and waits for all to resolve
@@ -450,17 +539,17 @@ class Rsx {
}
/**
* Get all page state from URL hash
* Get all hash state from URL hash
*
* Usage:
* ```javascript
* const state = Rsx.get_all_page_state();
* const state = Rsx.url_hash_get_all();
* // Returns: {dg_page: '2', dg_sort: 'name'}
* ```
*
* @returns {Object} All hash parameters as key-value pairs
*/
static get_all_page_state() {
static url_hash_get_all() {
return Rsx._parse_hash();
}
@@ -469,14 +558,14 @@ class Rsx {
*
* Usage:
* ```javascript
* const page = Rsx.get_page_state('dg_page');
* const page = Rsx.url_hash_get('dg_page');
* // Returns: '2' or null if not set
* ```
*
* @param {string} key The key to retrieve
* @returns {string|null} The value or null if not found
*/
static get_page_state(key) {
static url_hash_get(key) {
const state = Rsx._parse_hash();
return state[key] ?? null;
}
@@ -486,16 +575,16 @@ class Rsx {
*
* Usage:
* ```javascript
* Rsx.set_page_state('dg_page', 2);
* Rsx.url_hash_set_single('dg_page', 2);
* // URL becomes: http://example.com/page#dg_page=2
*
* Rsx.set_page_state('dg_page', null); // Remove key
* Rsx.url_hash_set_single('dg_page', null); // Remove key
* ```
*
* @param {string} key The key to set
* @param {string|number|null} value The value (null/empty removes the key)
*/
static set_page_state(key, value) {
static url_hash_set_single(key, value) {
const state = Rsx._parse_hash();
// Update or remove the key
@@ -516,13 +605,15 @@ class Rsx {
*
* Usage:
* ```javascript
* Rsx.set_all_page_state({dg_page: 2, dg_sort: 'name'});
* Rsx.url_hash_set({dg_page: 2, dg_sort: 'name'});
* // URL becomes: http://example.com/page#dg_page=2&dg_sort=name
*
* Rsx.url_hash_set({dg_page: null}); // Remove key from hash
* ```
*
* @param {Object} new_state Object with key-value pairs to set
* @param {Object} new_state Object with key-value pairs to set (null removes key)
*/
static set_all_page_state(new_state) {
static url_hash_set(new_state) {
const state = Rsx._parse_hash();
// Merge new state
@@ -601,7 +692,7 @@ class Rsx {
<div class="alert alert-warning" role="alert">
<h5>Validation Errors:</h5>
<ul class="mb-0">
${error_list.map(err => `<li>${Rsx._escape_html(err)}</li>`).join('')}
${error_list.map((err) => `<li>${Rsx._escape_html(err)}</li>`).join('')}
</ul>
</div>
`;

357
app/RSpade/Core/Js/Rsx_Storage.js Executable file
View File

@@ -0,0 +1,357 @@
/**
* Rsx_Storage - Scoped browser storage helper with graceful degradation
*
* Provides safe, scoped access to sessionStorage and localStorage with automatic
* handling of unavailable storage, quota exceeded errors, and scope invalidation.
*
* Key Features:
* - **Automatic scoping**: All keys scoped by cookie, user, site, and build version
* - **Graceful degradation**: Returns null when storage unavailable (private browsing, etc.)
* - **Quota management**: Auto-clears storage when full and retries operation
* - **Scope validation**: Clears storage when scope changes (user logout, build update, etc.)
* - **Developer-friendly keys**: Scoped suffix allows easy inspection in dev tools
*
* Scoping Strategy:
* Storage is scoped by combining:
* - `window.rsxapp.session_hash` (hashed session identifier, non-reversible)
* - `window.rsxapp.user.id` (current user)
* - `window.rsxapp.site.id` (current site)
* - `window.rsxapp.build_key` (application version)
*
* This scope is stored in `_rsx_scope_key`. If the scope changes between page loads,
* all RSpade keys are cleared to prevent stale data from different sessions/users/builds.
*
* Key Format:
* Keys are stored as: `rsx::developer_key::scope_suffix`
* Example: `rsx::flash_queue::abc123_42_1_v2.1.0`
*
* The `rsx::` prefix identifies RSpade keys, allowing safe clearing of only our keys
* without affecting other JavaScript libraries. This enables transparent coexistence
* with third-party libraries that also use browser storage.
*
* Quota Exceeded Handling:
* When storage quota is exceeded during a set operation, only RSpade keys (prefixed with
* `rsx::`) are cleared, preserving other libraries' data. The operation is then retried
* once. This ensures the application continues functioning even when storage is full.
*
* Usage:
* // Session storage (cleared on tab close)
* Rsx_Storage.session_set('user_preferences', {theme: 'dark'});
* const prefs = Rsx_Storage.session_get('user_preferences');
* Rsx_Storage.session_remove('user_preferences');
*
* // Local storage (persists across sessions)
* Rsx_Storage.local_set('cached_data', {items: [...]});
* const data = Rsx_Storage.local_get('cached_data');
* Rsx_Storage.local_remove('cached_data');
*
* IMPORTANT - Volatile Storage:
* Storage can be cleared at any time due to:
* - User clearing browser data
* - Private browsing mode restrictions
* - Quota exceeded errors
* - Scope changes (logout, build update, session change)
* - Browser storage unavailable
*
* Therefore, NEVER store critical data that impacts application functionality.
* Only store:
* - Cached data (performance optimization)
* - UI state (convenience, not required)
* - Transient messages (flash alerts, notifications)
*
* If the data is required for the application to function, store it server-side.
*/
class Rsx_Storage {
static _scope_suffix = null;
static _session_available = null;
static _local_available = null;
/**
* Initialize storage system and validate scope
* Called automatically on first access
* @private
*/
static _init() {
// Check if already initialized
if (this._scope_suffix !== null) {
return;
}
// Check storage availability
this._session_available = this._is_storage_available('sessionStorage');
this._local_available = this._is_storage_available('localStorage');
// Calculate current scope suffix
this._scope_suffix = Rsx.scope_key();
// Validate scope for both storages
if (this._session_available) {
this._validate_scope(sessionStorage, 'session');
}
if (this._local_available) {
this._validate_scope(localStorage, 'local');
}
}
/**
* Check if a storage type is available
* @param {string} type - 'sessionStorage' or 'localStorage'
* @returns {boolean}
* @private
*/
static _is_storage_available(type) {
try {
const storage = window[type];
const test = '__rsx_storage_test__';
storage.setItem(test, test);
storage.removeItem(test);
return true;
} catch (e) {
return false;
}
}
/**
* Validate storage scope and clear RSpade keys if changed
* Only clears keys prefixed with 'rsx::' to preserve other libraries' data
* @param {Storage} storage - sessionStorage or localStorage
* @param {string} type - 'session' or 'local' (for logging)
* @private
*/
static _validate_scope(storage, type) {
try {
const stored_scope = storage.getItem('_rsx_scope_key');
// If scope key exists and has changed, clear only RSpade keys
if (stored_scope !== null && stored_scope !== this._scope_suffix) {
console.log(`[Rsx_Storage] Scope changed for ${type}Storage, clearing RSpade keys only:`, {
old_scope: stored_scope,
new_scope: this._scope_suffix,
});
this._clear_rsx_keys(storage);
storage.setItem('_rsx_scope_key', this._scope_suffix);
} else if (stored_scope === null) {
// First time RSpade is using this storage - just set the key, don't clear
console.log(`[Rsx_Storage] Initializing scope for ${type}Storage (first use):`, {
new_scope: this._scope_suffix,
});
storage.setItem('_rsx_scope_key', this._scope_suffix);
}
} catch (e) {
console.error(`[Rsx_Storage] Failed to validate scope for ${type}Storage:`, e);
}
}
/**
* Clear only RSpade keys from storage (keys starting with 'rsx::')
* Preserves keys from other libraries
* @param {Storage} storage - sessionStorage or localStorage
* @private
*/
static _clear_rsx_keys(storage) {
const keys_to_remove = [];
// Collect all RSpade keys
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (key && key.startsWith('rsx::')) { // @JS-DEFENSIVE-01-EXCEPTION - Browser API storage.key(i) can return null when i >= storage.length
keys_to_remove.push(key);
}
}
// Remove collected keys
keys_to_remove.forEach((key) => {
try {
storage.removeItem(key);
} catch (e) {
console.error('[Rsx_Storage] Failed to remove key:', key, e);
}
});
console.log(`[Rsx_Storage] Cleared ${keys_to_remove.length} RSpade keys`);
}
/**
* Build scoped key with RSpade namespace prefix
* @param {string} key - Developer-provided key
* @returns {string}
* @private
*/
static _build_key(key) {
return `rsx::${key}::${this._scope_suffix}`;
}
/**
* Set item in sessionStorage
* @param {string} key - Storage key
* @param {*} value - Value to store (will be JSON serialized)
*/
static session_set(key, value) {
this._init();
if (!this._session_available) {
return;
}
this._set_item(sessionStorage, key, value, 'session');
}
/**
* Get item from sessionStorage
* @param {string} key - Storage key
* @returns {*|null} Parsed value or null if not found/unavailable
*/
static session_get(key) {
this._init();
if (!this._session_available) {
return null;
}
return this._get_item(sessionStorage, key);
}
/**
* Remove item from sessionStorage
* @param {string} key - Storage key
*/
static session_remove(key) {
this._init();
if (!this._session_available) {
return;
}
this._remove_item(sessionStorage, key);
}
/**
* Set item in localStorage
* @param {string} key - Storage key
* @param {*} value - Value to store (will be JSON serialized)
*/
static local_set(key, value) {
this._init();
if (!this._local_available) {
return;
}
this._set_item(localStorage, key, value, 'local');
}
/**
* Get item from localStorage
* @param {string} key - Storage key
* @returns {*|null} Parsed value or null if not found/unavailable
*/
static local_get(key) {
this._init();
if (!this._local_available) {
return null;
}
return this._get_item(localStorage, key);
}
/**
* Remove item from localStorage
* @param {string} key - Storage key
*/
static local_remove(key) {
this._init();
if (!this._local_available) {
return;
}
this._remove_item(localStorage, key);
}
/**
* Internal set implementation with scope validation and quota handling
* @param {Storage} storage
* @param {string} key
* @param {*} value
* @param {string} type - 'session' or 'local' (for logging)
* @private
*/
static _set_item(storage, key, value, type) {
// Validate scope before every write
this._validate_scope(storage, type);
const scoped_key = this._build_key(key);
try {
const serialized = JSON.stringify(value);
// Check size - skip if larger than 1MB
const size_bytes = new Blob([serialized]).size;
const size_mb = size_bytes / (1024 * 1024);
if (size_mb > 1) {
console.warn(`[Rsx_Storage] Skipping storage for key "${key}" - data too large (${size_mb.toFixed(2)} MB, limit 1 MB)`);
return;
}
storage.setItem(scoped_key, serialized);
} catch (e) {
// Check if quota exceeded
if (e.name === 'QuotaExceededError' || e.code === 22) {
console.warn(`[Rsx_Storage] Quota exceeded for ${type}Storage, clearing RSpade keys and retrying`);
// Clear only RSpade keys and retry once
this._clear_rsx_keys(storage);
storage.setItem('_rsx_scope_key', this._scope_suffix);
try {
const serialized = JSON.stringify(value);
storage.setItem(scoped_key, serialized);
} catch (retry_error) {
console.error(`[Rsx_Storage] Failed to set item after clearing RSpade keys from ${type}Storage:`, retry_error);
}
} else {
console.error(`[Rsx_Storage] Failed to set item in ${type}Storage:`, e);
}
}
}
/**
* Internal get implementation
* @param {Storage} storage
* @param {string} key
* @returns {*|null}
* @private
*/
static _get_item(storage, key) {
const scoped_key = this._build_key(key);
try {
const serialized = storage.getItem(scoped_key);
if (serialized === null) {
return null;
}
return JSON.parse(serialized);
} catch (e) {
console.error('[Rsx_Storage] Failed to get item:', e);
return null;
}
}
/**
* Internal remove implementation
* @param {Storage} storage
* @param {string} key
* @private
*/
static _remove_item(storage, key) {
const scoped_key = this._build_key(key);
try {
storage.removeItem(scoped_key);
} catch (e) {
console.error('[Rsx_Storage] Failed to remove item:', e);
}
}
}

View File

@@ -459,3 +459,79 @@ function csv_to_array_trim(str_csv) {
});
return ret;
}
// ============================================================================
// URL UTILITIES
// ============================================================================
/**
* Convert a full URL to short URL by removing protocol
*
* Strips http:// or https:// from the beginning of the URL if present.
* Leaves the URL alone if it doesn't start with either protocol.
* Removes trailing slash if there is no path.
*
* @param {string|null} url - URL to convert
* @returns {string|null} Short URL without protocol
*/
function full_url_to_short_url(url) {
if (url === null || url === undefined || url === '') {
return url;
}
// Convert to string if needed
url = String(url);
// Remove http:// or https:// from the beginning (case-insensitive)
if (url.toLowerCase().indexOf('http://') === 0) {
url = url.substring(7);
} else if (url.toLowerCase().indexOf('https://') === 0) {
url = url.substring(8);
}
// Remove trailing slash if there is no path (just domain)
// Check if URL is just domain with trailing slash (no path after slash)
if (url.endsWith('/') && (url.match(/\//g) || []).length === 1) {
url = url.replace(/\/$/, '');
}
return url;
}
/**
* Convert a short URL to full URL by adding protocol
*
* Adds http:// to the beginning of the URL if it lacks a protocol.
* Leaves URLs with existing http:// or https:// unchanged.
* Adds trailing slash if there is no path.
*
* @param {string|null} url - URL to convert
* @returns {string|null} Full URL with protocol
*/
function short_url_to_full_url(url) {
if (url === null || url === undefined || url === '') {
return url;
}
// Convert to string if needed
url = String(url);
let full_url;
// Check if URL already has a protocol (case-insensitive)
if (url.toLowerCase().indexOf('http://') === 0 || url.toLowerCase().indexOf('https://') === 0) {
full_url = url;
} else {
// Add http:// protocol
full_url = 'http://' + url;
}
// Add trailing slash if there is no path (just domain)
// Check if URL has no slash after the domain
const without_protocol = full_url.replace(/^https?:\/\//i, '');
if (without_protocol.indexOf('/') === -1) {
full_url += '/';
}
return full_url;
}

View File

@@ -840,100 +840,22 @@ class Manifest
/**
* Get all routes from the manifest
*
* Returns unified route structure: $routes[$pattern] => route_data
* where route_data contains:
* - methods: ['GET', 'POST']
* - type: 'spa' | 'standard'
* - class: Full class name
* - method: Method name
* - file: File path
* - require: Auth requirements
* - js_action_class: (SPA routes only) JavaScript action class
*/
public static function get_routes(): array
{
static::init();
$routes = [
'controllers' => [],
'api' => [],
];
// Look for Route attributes - must check all namespaces since Route is not a real class
// PHP attributes without an import will use the current namespace
$files = static::get_all();
$route_classes = [];
foreach ($files as $file => $metadata) {
// Check public static method attributes for any attribute ending with 'Route'
if (isset($metadata['public_static_methods'])) {
foreach ($metadata['public_static_methods'] as $method_name => $method_data) {
if (isset($method_data['attributes'])) {
foreach ($method_data['attributes'] as $attr_name => $attr_instances) {
// Check if this is a Route attribute (ends with \Route or is just Route)
if (str_ends_with($attr_name, '\\Route') || $attr_name === 'Route') {
$route_classes[] = [
'file' => $file,
'class' => $metadata['class'] ?? null,
'fqcn' => $metadata['fqcn'] ?? null,
'method' => $method_name,
'type' => 'method',
'instances' => $attr_instances,
];
}
}
}
}
}
}
foreach ($route_classes as $item) {
if ($item['type'] === 'method') {
foreach ($item['instances'] as $route_args) {
$pattern = $route_args[0] ?? ($route_args['pattern'] ?? null);
$methods = $route_args[1] ?? ($route_args['methods'] ?? ['GET']);
$name = $route_args[2] ?? ($route_args['name'] ?? null);
if ($pattern) {
// Ensure pattern starts with /
if ($pattern[0] !== '/') {
$pattern = '/' . $pattern;
}
// Determine type (API or controller)
$type = str_contains($item['file'], '/api/') || str_contains($item['class'] ?? '', 'Api') ? 'api' : 'controllers';
// Initialize route if not exists
if (!isset($routes[$type][$pattern])) {
$routes[$type][$pattern] = [];
}
// Extract Auth attributes for this method from the file metadata
$require_attrs = [];
$file_metadata = $files[$item['file']] ?? null;
if ($file_metadata && isset($file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'])) {
$require_attrs = $file_metadata['public_static_methods'][$item['method']]['attributes']['Auth'];
}
// Add for each HTTP method
foreach ((array) $methods as $method) {
$method_upper = strtoupper($method);
// Initialize method array if not exists
if (!isset($routes[$type][$pattern][$method_upper])) {
$routes[$type][$pattern][$method_upper] = [];
}
// Add handler to array (allows duplicates for dispatch-time detection)
$routes[$type][$pattern][$method_upper][] = [
'class' => $item['fqcn'] ?? $item['class'],
'method' => $item['method'],
'name' => $name,
'file' => $item['file'],
'require' => $require_attrs,
];
}
}
}
}
}
// Sort routes alphabetically by path to ensure deterministic behavior and prevent race condition bugs
ksort($routes['controllers']);
ksort($routes['api']);
return $routes;
return static::$data['data']['routes'] ?? [];
}
/**
@@ -1115,8 +1037,8 @@ class Manifest
if ($loaded_cache && env('APP_ENV') == 'production') {
// In prod mode, if cache loaded, assume the cache is good, we are done with it
console_debug('MANIFEST', 'Manifest cache loaded successfully (production mode)');
self::$_has_manifest_ready = true;
\App\RSpade\Core\Autoloader::register();
self::post_init();
return;
}
@@ -1125,8 +1047,8 @@ class Manifest
console_debug('MANIFEST', 'Manifest cache loaded successfully (development mode), validating...');
if (self::__validate_cached_data()) {
console_debug('MANIFEST', 'Manifest is valid');
self::$_has_manifest_ready = true;
\App\RSpade\Core\Autoloader::register();
self::post_init();
return;
}
@@ -1183,8 +1105,37 @@ class Manifest
RsxLocks::release_lock(self::$_manifest_compile_lock);
console_debug('MANIFEST', 'Released manifest build lock');
self::post_init();
}
/**
* Post-initialization hook called after manifest is fully loaded
*
* Called at the end of init() after manifest data is loaded (either from cache
* or after scanning/rebuilding). At this point, the manifest is complete and
* ready for queries.
*
* Current responsibilities:
* - Sets $_has_manifest_ready flag to indicate manifest is available
* - Registers the autoloader (which depends on manifest data)
* - Loads classless PHP files (helpers, constants, procedural code)
*
* This is the appropriate place to perform operations that require the complete
* manifest to be available, such as loading non-class PHP files that were
* indexed during the scan.
*/
public static function post_init() {
self::$_has_manifest_ready = true;
\App\RSpade\Core\Autoloader::register();
// Load classless PHP files (helper functions, constants, etc.)
$classless_files = self::$data['data']['classless_php_files'] ?? [];
foreach ($classless_files as $file_path) {
$full_path = base_path($file_path);
if (file_exists($full_path)) {
include_once $full_path;
}
}
}
/**
@@ -1310,6 +1261,7 @@ class Manifest
'data' => [
'files' => $existing_files,
'autoloader_class_map' => [],
'routes' => [],
],
];
@@ -1467,6 +1419,9 @@ class Manifest
// Build event handler index from attributes
static::__build_event_handler_index();
// Build classless PHP files index
static::__build_classless_php_files_index();
// =======================================================
// Phase 5: Process Modules - Run manifest support modules and build autoloader
// =======================================================
@@ -1903,6 +1858,36 @@ class Manifest
}
}
/**
* Build index of classless PHP files
*
* Creates a simple array of file paths for all PHP files in the manifest
* that do not contain a class. These files typically contain helper functions,
* constants, or other procedural code that needs to be loaded during post_init().
*
* Stored in manifest data at: $data['data']['classless_php_files']
*/
protected static function __build_classless_php_files_index()
{
static::$data['data']['classless_php_files'] = [];
// Scan all PHP files
foreach (static::$data['data']['files'] as $file_path => $metadata) {
// Only process PHP files
if (($metadata['extension'] ?? '') !== 'php') {
continue;
}
// Skip files that have a class
if (!empty($metadata['class'])) {
continue;
}
// Add file path to classless index
static::$data['data']['classless_php_files'][] = $file_path;
}
}
/**
* Load changed PHP files and their dependencies
*
@@ -3167,15 +3152,21 @@ class Manifest
// Check for conflicting attributes
$conflicts = [];
if ($has_route) $conflicts[] = 'Route';
if ($has_ajax_endpoint) $conflicts[] = 'Ajax_Endpoint';
if ($has_task) $conflicts[] = 'Task';
if ($has_route) {
$conflicts[] = 'Route';
}
if ($has_ajax_endpoint) {
$conflicts[] = 'Ajax_Endpoint';
}
if ($has_task) {
$conflicts[] = 'Task';
}
if (count($conflicts) > 1) {
$class_name = $metadata['class'] ?? 'Unknown';
throw new \RuntimeException(
"Method cannot have multiple execution type attributes: " . implode(', ', $conflicts) . "\n" .
'Method cannot have multiple execution type attributes: ' . implode(', ', $conflicts) . "\n" .
"Class: {$class_name}\n" .
"Method: {$method_name}\n" .
"File: {$file_path}\n" .

View File

@@ -18,7 +18,7 @@ class Filename_Suggester
* @param string $class_name Class name
* @param string $extension File extension (php, js)
* @param bool $is_rspade True if in app/RSpade/, false if in rsx/
* @param bool $is_jqhtml_component True if JS class extends Jqhtml_Component
* @param bool $is_jqhtml_component True if JS class extends Component
* @return string Suggested filename
*/
public static function get_suggested_class_filename(

View File

@@ -86,6 +86,16 @@ class Rsx_Framework_Provider extends ServiceProvider
config(['rsx' => $merged_config]);
}
// Merge additional config from RSX_ADDITIONAL_CONFIG env variable
// Used by test runner to include test directory in manifest
$additional_config_path = env('RSX_ADDITIONAL_CONFIG');
if ($additional_config_path && file_exists($additional_config_path)) {
$additional_config = require $additional_config_path;
$current_config = config('rsx', []);
$merged_config = array_merge_deep($current_config, $additional_config);
config(['rsx' => $merged_config]);
}
// Remove .claude/CLAUDE.md symlink if in framework developer mode
// This allows dual CLAUDE.md files: one for framework dev, one for distribution
// Only runs in specific development environment to avoid affecting other setups
@@ -291,6 +301,9 @@ class Rsx_Framework_Provider extends ServiceProvider
// Register RSX view namespace for path-agnostic views
$this->app['view']->addNamespace('rsx', base_path('rsx'));
// Register RSpade framework view namespace
$this->app['view']->addNamespace('rspade', base_path('app/RSpade'));
// Register RSX route macro on Route facade
if (!Route::hasMacro('rsx')) {
Route::macro('rsx', function ($target, $params = []) {

View File

@@ -9,12 +9,10 @@
namespace App\RSpade\Core;
use App\Models\FlashAlert;
use RuntimeException;
use App\RSpade\Core\Debug\Rsx_Caller_Exception;
use App\RSpade\Core\Events\Event_Registry;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Session\Session;
/**
* Core RSX framework utility class
@@ -42,14 +40,21 @@ class Rsx
*/
protected static $current_params = null;
/**
* Current route type ('spa' or 'standard')
* @var string|null
*/
protected static $current_route_type = null;
/**
* Set the current controller and action being executed
*
* @param string $controller_class The controller class name
* @param string $action_method The action method name
* @param array $params Optional request params to store
* @param string|null $route_type Route type ('spa' or 'standard')
*/
public static function _set_current_controller_action($controller_class, $action_method, array $params = [])
public static function _set_current_controller_action($controller_class, $action_method, array $params = [], $route_type = null)
{
// Extract just the class name without namespace
$parts = explode('\\', $controller_class);
@@ -58,6 +63,7 @@ class Rsx
static::$current_controller = $class_name;
static::$current_action = $action_method;
static::$current_params = $params;
static::$current_route_type = $route_type;
}
/**
@@ -90,6 +96,16 @@ class Rsx
return static::$current_params;
}
/**
* Check if current route is a SPA route
*
* @return bool True if current route type is 'spa', false otherwise
*/
public static function is_spa()
{
return static::$current_route_type === 'spa';
}
/**
* Clear the current controller and action tracking
*/
@@ -98,117 +114,17 @@ class Rsx
static::$current_controller = null;
static::$current_action = null;
static::$current_params = null;
static::$current_route_type = null;
}
/**
* Add a flash alert message for the current session
*
* @param string $message The message to display
* @param string $class_attribute Optional CSS class attribute (defaults to 'alert alert-danger alert-flash')
* @return void
*/
public static function flash_alert($message, $class_attribute = 'alert alert-danger alert-flash')
{
$session_id = Session::get_session_id();
if ($session_id === null) {
return;
}
$flash_alert = new FlashAlert();
$flash_alert->session_id = $session_id;
$flash_alert->message = $message;
$flash_alert->class_attribute = $class_attribute;
$flash_alert->created_at = now();
$flash_alert->save();
}
/**
* Render all flash alerts for the current session
*
* Returns HTML for all flash messages and deletes them from the database.
* Messages are rendered as Bootstrap 5 alerts with dismissible buttons.
*
* @return string HTML string containing all flash alerts or empty string
*/
public static function render_flash_alerts()
{
$session_id = Session::get_session_id();
if ($session_id === null) {
return '';
}
// Get all flash alerts for this session
$alerts = FlashAlert::where('session_id', $session_id)
->orderBy('created_at', 'asc')
->get();
if ($alerts->isEmpty()) {
return '';
}
// Delete the alerts now that we're rendering them
FlashAlert::where('session_id', $session_id)
->delete();
// Build HTML for all alerts
$html = '';
foreach ($alerts as $alert) {
$message = htmlspecialchars($alert->message);
$class = htmlspecialchars($alert->class_attribute);
$html .= <<<HTML
<div class="{$class} show" role="alert">
{$message}
</div>
HTML;
}
return $html;
}
/**
* Helper method to add a success flash alert
*
* @param string $message
* @return void
*/
public static function flash_success($message)
{
self::flash_alert($message, 'alert alert-success alert-flash');
}
/**
* Helper method to add an error flash alert
*
* @param string $message
* @return void
*/
public static function flash_error($message)
{
self::flash_alert($message, 'alert alert-danger alert-flash');
}
/**
* Helper method to add a warning flash alert
*
* @param string $message
* @return void
*/
public static function flash_warning($message)
{
self::flash_alert($message, 'alert alert-warning alert-flash');
}
/**
* Helper method to add an info flash alert
*
* @param string $message
* @return void
*/
public static function flash_info($message)
{
self::flash_alert($message, 'alert alert-info alert-flash');
}
// Flash alert methods have been removed - use Flash class instead:
// Flash_Alert::success($message)
// Flash_Alert::error($message)
// Flash_Alert::info($message)
// Flash_Alert::warning($message)
//
// See: /system/app/RSpade/Core/Flash/Flash.php
// See: /system/app/RSpade/Core/Flash/CLAUDE.md
/**
* Generate URL for a controller route
@@ -223,42 +139,54 @@ HTML;
*
* Usage examples:
* ```php
* // Simple route without parameters (defaults to 'index' action)
* // Controller route (defaults to 'index' method)
* $url = Rsx::Route('Frontend_Index_Controller');
* // Returns: /dashboard
*
* // Route with explicit action
* $url = Rsx::Route('Frontend_Index_Controller', 'index');
* // Returns: /dashboard
*
* // Route with integer parameter (sets 'id')
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', 123);
* // Controller route with explicit method
* $url = Rsx::Route('Frontend_Client_View_Controller::view', 123);
* // Returns: /clients/view/123
*
* // SPA action route
* $url = Rsx::Route('Contacts_Index_Action');
* // Returns: /contacts
*
* // Route with integer parameter (sets 'id')
* $url = Rsx::Route('Contacts_View_Action', 123);
* // Returns: /contacts/123
*
* // Route with named parameters (array)
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', ['id' => 'C001']);
* // Returns: /clients/view/C001
* $url = Rsx::Route('Contacts_View_Action', ['id' => 'C001']);
* // Returns: /contacts/C001
*
* // Route with required and query parameters
* $url = Rsx::Route('Frontend_Client_View_Controller', 'view', [
* $url = Rsx::Route('Contacts_View_Action', [
* 'id' => 'C001',
* 'tab' => 'history'
* ]);
* // Returns: /clients/view/C001?tab=history
* // Returns: /contacts/C001?tab=history
*
* // Placeholder route for scaffolding (controller doesn't need to exist)
* $url = Rsx::Route('Future_Feature_Controller', '#index');
* // Placeholder route for scaffolding (doesn't need to exist)
* $url = Rsx::Route('Future_Feature_Controller::#index');
* // Returns: #
* ```
*
* @param string $class_name The controller class name (e.g., 'User_Controller')
* @param string $action_name The action/method name (defaults to 'index'). Use '#action' for placeholders.
* @param string $action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
* @param int|array|\stdClass|null $params Route parameters. Integer sets 'id', array/object provides named params.
* @return string The generated URL
* @throws RuntimeException If class doesn't exist, isn't a controller, method doesn't exist, or lacks Route attribute
* @throws RuntimeException If class doesn't exist, isn't a controller/action, method doesn't exist, or lacks Route attribute
*/
public static function Route($class_name, $action_name = 'index', $params = null)
public static function Route($action, $params = null)
{
// Parse action into class_name and action_name
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
if (str_contains($action, '::')) {
[$class_name, $action_name] = explode('::', $action, 2);
} else {
$class_name = $action;
$action_name = 'index';
}
// Normalize params to array
$params_array = [];
if (is_int($params)) {
@@ -281,8 +209,8 @@ HTML;
try {
$metadata = Manifest::php_get_metadata_by_class($class_name);
} catch (RuntimeException $e) {
// Report error at caller's location (the blade template or PHP code calling Rsx::Route)
throw new Rsx_Caller_Exception("Could not generate route URL: controller class {$class_name} not found");
// Not found as PHP class - might be a SPA action, try that instead
return static::_try_spa_action_route($class_name, $params_array);
}
// Verify it extends Rsx_Controller_Abstract
@@ -366,7 +294,8 @@ HTML;
}
if (!$has_route) {
throw new Rsx_Caller_Exception("Method {$class_name}::{$action_name} must have Route or Ajax_Endpoint attribute");
// Not a controller method with Route/Ajax - check if it's a SPA action class
return static::_try_spa_action_route($class_name, $params_array);
}
if (!$route_pattern) {
@@ -377,6 +306,68 @@ HTML;
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, $action_name);
}
/**
* Try to generate URL for a SPA action class
* Called when class lookup fails for controller - checks if it's a JavaScript SPA action
*
* @param string $class_name The class name (might be a JS SPA action)
* @param array $params_array Parameters for URL generation
* @return string The generated URL
* @throws Rsx_Caller_Exception If not a valid SPA action or route not found
*/
protected static function _try_spa_action_route(string $class_name, array $params_array): string
{
// Check if this is a JavaScript class that extends Spa_Action
try {
$is_spa_action = Manifest::js_is_subclass_of($class_name, 'Spa_Action');
} catch (\RuntimeException $e) {
// Not a JS class or not found
throw new Rsx_Caller_Exception("Class {$class_name} must extend Rsx_Controller_Abstract or Spa_Action");
}
if (!$is_spa_action) {
throw new Rsx_Caller_Exception("JavaScript class {$class_name} must extend Spa_Action to generate routes");
}
// Get the file path for this JS class
try {
$file_path = Manifest::js_find_class($class_name);
} catch (\RuntimeException $e) {
throw new Rsx_Caller_Exception("SPA action class {$class_name} not found in manifest");
}
// Get file metadata which contains decorator information
try {
$file_data = Manifest::get_file($file_path);
} catch (\RuntimeException $e) {
throw new Rsx_Caller_Exception("File metadata not found for SPA action {$class_name}");
}
// Extract route pattern from decorators
// JavaScript files have 'decorators' array in their metadata
// Format: [[0 => 'route', 1 => ['/contacts']], ...]
$route_pattern = null;
if (isset($file_data['decorators']) && is_array($file_data['decorators'])) {
foreach ($file_data['decorators'] as $decorator) {
// Decorator format: [0 => 'decorator_name', 1 => [arguments]]
if (isset($decorator[0]) && $decorator[0] === 'route') {
// First argument is the route pattern
if (isset($decorator[1][0])) {
$route_pattern = $decorator[1][0];
break;
}
}
}
}
if (!$route_pattern) {
throw new Rsx_Caller_Exception("SPA action {$class_name} must have @route() decorator with pattern");
}
// Generate URL from pattern using same logic as regular routes
return static::_generate_url_from_pattern($route_pattern, $params_array, $class_name, '(SPA action)');
}
/**
* Generate URL from route pattern by replacing parameters
*

118
app/RSpade/Core/SPA/CLAUDE.md Executable file
View File

@@ -0,0 +1,118 @@
# RSpade Spa System
## Overview
The RSpade Spa system enables client-side routing for authenticated areas of applications using the JQHTML component framework.
## Core Classes
### Spa_Action
Base class for Spa pages/routes. Each action represents a distinct page in the application.
**Example:**
```javascript
class Users_View_Action extends Spa_Action {
static route = '/users/:user_id';
static layout = 'Frontend_Layout';
on_create() {
console.log(this.args.user_id); // URL parameter from route
}
}
```
### Spa_Layout
Persistent wrapper component containing navigation, header, footer, etc. Layouts have a content area where actions render.
**Example:**
```javascript
class Frontend_Layout extends Spa_Layout {
on_create() {
// Initialize layout structure
// Create navigation, header, footer
// Define content area for actions
}
}
```
### Spa
Main Spa application class that initializes the router and manages navigation between actions.
### Spa_Router
Core routing engine adapted from JQHTML framework. Handles URL matching, parameter extraction, and navigation.
## Discovery
Spa actions are discovered during manifest build using:
```php
Manifest::is_js_subclass_of('Spa_Action')
```
No filename conventions required - any class extending `Spa_Action` is automatically registered.
## URL Generation
**PHP:**
```php
$url = Rsx::SpaRoute('Users_View_Action', ['user_id' => 123]);
// Returns: "/users/123"
```
**JavaScript:**
```javascript
const url = Rsx.SpaRoute('Users_View_Action', {user_id: 123, tab: 'posts'});
// Returns: "/users/123?tab=posts"
```
## Architecture
### Bootstrap Flow
1. User requests Spa route (e.g., `/users/123`)
2. Dispatcher detects Spa route in manifest
3. Server renders `spa_bootstrap.blade.php` with bundle
4. Client initializes Spa application
5. Router matches URL to action
6. Layout renders with action inside
### Navigation Flow
1. User clicks link or calls `Rsx.SpaRoute()`
2. Router matches URL to action class
3. Current action destroyed (if exists)
4. New action instantiated with URL parameters
5. Action renders within persistent layout
6. Browser history updated
## Implementation Status
**Phase 1: Foundation Setup** - ✅ COMPLETE
- Directory structure created
- Placeholder files in place
- Router code copied from JQHTML export
**Phase 2+:** See `/var/www/html/docs.dev/Spa_INTEGRATION_PLAN.md`
## Directory Structure
```
/system/app/RSpade/Core/Jqhtml_Spa/
├── CLAUDE.md # This file
├── Spa_Bootstrap.php # Server-side bootstrap handler
├── Spa_Parser.php # Parse JS files for action metadata
├── Spa_Manifest.php # Manifest integration helper
├── spa_bootstrap.blade.php # Bootstrap blade layout
├── Spa_Router.js # Core router (from JQHTML)
├── Spa.js # Spa application base class
├── Spa_Layout.js # Layout base class
└── Spa_Action.js # Action base class
```
## Notes
- Spa routes are auth-only (no SEO concerns)
- Server does not render Spa pages (client-side only)
- Actions live alongside PHP controllers in feature directories
- Controllers provide Ajax endpoints, actions provide UI
- Layouts persist across action navigation (no re-render)
- Component renamed from `Component` to `Component` (legacy alias maintained)

640
app/RSpade/Core/SPA/Spa.js Executable file
View File

@@ -0,0 +1,640 @@
/**
* Spa - Single Page Application orchestrator for RSpade
*
* This class manages the Spa lifecycle:
* - Auto-discovers action classes extending Spa_Action
* - Extracts route information from decorator metadata
* - Registers routes with the router
* - Dispatches to the current URL
*
* Initialization happens automatically during the on_app_init phase when
* window.rsxapp.is_spa === true.
*
* Unlike JQHTML, this is a static class (not a Component) following the RS3 pattern.
*/
class Spa {
// Registered routes: { pattern: action_class }
static routes = {};
// Current layout instance
static layout = null;
// Current action instance
static action = null;
// Current route instance
static route = null;
// Current route 'params'
static params = null;
// Flag to prevent re-entrant dispatch
static is_dispatching = false;
/**
* Framework module initialization hook called during framework boot
* Only runs when window.rsxapp.is_spa === true
*/
static _on_framework_modules_init() {
// Only initialize Spa if we're in a Spa route
if (!window.rsxapp || !window.rsxapp.is_spa) {
return;
}
console_debug('Spa', 'Initializing Spa system');
// Discover and register all action classes
Spa.discover_actions();
// Setup browser integration using History API
// Note: Navigation API evaluated but not mature enough for production use
// See: /docs.dev/SPA_BROWSER_INTEGRATION.md for details
console.log('[Spa] Using History API for browser integration');
Spa.setup_browser_integration();
// Dispatch to current URL (including hash for initial load)
const initial_url = window.location.pathname + window.location.search + window.location.hash;
console_debug('Spa', 'Dispatching to initial URL: ' + initial_url);
Spa.dispatch(initial_url, { history: 'none' });
}
/**
* Discover all classes extending Spa_Action and register their routes
*/
static discover_actions() {
const all_classes = Manifest.get_all_classes();
let action_count = 0;
for (const class_info of all_classes) {
const class_object = class_info.class_object;
const class_name = class_info.class_name;
// Check if this class extends Spa_Action
if (class_object.prototype instanceof Spa_Action || class_object === Spa_Action) {
// Skip the base class itself
if (class_object === Spa_Action) {
continue;
}
// Extract route patterns from decorator metadata
const routes = class_object._spa_routes || [];
if (routes.length === 0) {
console.warn(`Spa: Action ${class_name} has no routes defined`);
continue;
}
// Register each route pattern
for (const pattern of routes) {
Spa.register_route(pattern, class_object);
console_debug('Spa', `Registered route: ${pattern}${class_name}`);
}
action_count++;
}
}
console_debug('Spa', `Discovered ${action_count} action classes`);
}
/**
* Register a route pattern to an action class
*/
static register_route(pattern, action_class) {
// Normalize pattern - remove trailing /index
if (pattern.endsWith('/index')) {
pattern = pattern.slice(0, -6) || '/';
}
// Check for duplicates in dev mode
if (Rsx.is_dev() && Spa.routes[pattern]) {
console.error(`Spa: Duplicate route '${pattern}' - ${action_class.name} conflicts with ${Spa.routes[pattern].name}`);
}
Spa.routes[pattern] = action_class;
}
/**
* Match URL to a route and extract parameters
* Returns: { action_class, args, layout } or null
*/
static match_url_to_route(url) {
// Parse URL to get path and query params
const parsed = Spa.parse_url(url);
let path = parsed.path;
// Normalize path - remove leading/trailing slashes for matching
path = path.substring(1); // Remove leading /
// Remove /index suffix
if (path === 'index' || path.endsWith('/index')) {
path = path.slice(0, -5) || '';
}
// Try exact match first
const exact_pattern = '/' + path;
if (Spa.routes[exact_pattern]) {
return {
action_class: Spa.routes[exact_pattern],
args: parsed.query_params,
layout: Spa.routes[exact_pattern]._spa_layout || 'Default_Layout',
};
}
// Try pattern matching with :param segments
for (const pattern in Spa.routes) {
const match = Spa.match_pattern(path, pattern);
if (match) {
// Merge parameters with correct priority order:
// 1. GET parameters (from query string, lowest priority)
// 2. URL route parameters (extracted from route pattern like :id, highest priority)
// This matches the PHP Dispatcher behavior where route params override GET params
const args = { ...parsed.query_params, ...match };
return {
action_class: Spa.routes[pattern],
args: args,
layout: Spa.routes[pattern]._spa_layout || 'Default_Layout',
};
}
}
// No match found
return null;
}
/**
* Match a path against a pattern with :param segments
* Returns object with extracted params or null if no match
*/
static match_pattern(path, pattern) {
// Remove leading / from both
path = path.replace(/^\//, '');
pattern = pattern.replace(/^\//, '');
// Split into segments
const path_segments = path.split('/');
const pattern_segments = pattern.split('/');
// Must have same number of segments
if (path_segments.length !== pattern_segments.length) {
return null;
}
const params = {};
for (let i = 0; i < pattern_segments.length; i++) {
const pattern_seg = pattern_segments[i];
const path_seg = path_segments[i];
if (pattern_seg.startsWith(':')) {
// This is a parameter - extract it
const param_name = pattern_seg.substring(1);
params[param_name] = decodeURIComponent(path_seg);
} else {
// This is a literal - must match exactly
if (pattern_seg !== path_seg) {
return null;
}
}
}
return params;
}
/**
* Parse URL into components
*/
static parse_url(url) {
let parsed_url;
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
parsed_url = new URL(url);
} else {
parsed_url = new URL(url, window.location.href);
}
} catch (e) {
parsed_url = new URL(window.location.href);
}
const path = parsed_url.pathname;
const search = parsed_url.search;
// Parse query string
const query_params = {};
if (search && search !== '?') {
const query = search.startsWith('?') ? search.substring(1) : search;
const pairs = query.split('&');
for (const pair of pairs) {
const [key, value] = pair.split('=');
if (key) {
query_params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
}
}
}
return { path, search, query_params };
}
/**
* Generate URL from pattern and parameters
*/
static generate_url_from_pattern(pattern, params = {}) {
let url = pattern;
const used_params = new Set();
// Replace :param placeholders
url = url.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, param_name) => {
if (params.hasOwnProperty(param_name)) {
used_params.add(param_name);
return encodeURIComponent(params[param_name]);
}
return '';
});
// Collect unused parameters for query string
const query_params = {};
for (const key in params) {
if (!used_params.has(key)) {
query_params[key] = params[key];
}
}
// Add query string if needed
if (Object.keys(query_params).length > 0) {
const query_string = Object.entries(query_params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
url += '?' + query_string;
}
return url;
}
/**
* Setup browser integration for back/forward and link interception
*
* This implements Phase 1 of browser integration using the History API.
* See: /docs.dev/SPA_BROWSER_INTEGRATION.md for complete documentation.
*
* Key Behaviors:
* - Intercepts clicks on <a> tags for same-domain SPA routes
* - Preserves standard browser behaviors (Ctrl+click, target="_blank", etc.)
* - Handles back/forward navigation with scroll restoration
* - Hash-only changes don't create history entries
* - Defers to server for edge cases (external links, non-SPA routes, etc.)
*/
static setup_browser_integration() {
console_debug('Spa', 'Setting up browser integration (History API mode)');
// Handle browser back/forward buttons
window.addEventListener('popstate', (e) => {
console_debug('Spa', 'popstate event fired (back/forward navigation)');
console.warn('[Spa.dispatch] Handling history popstate event', {
url: window.location.pathname + window.location.search + window.location.hash,
state: e.state
});
// Get target URL (browser has already updated location)
const url = window.location.pathname + window.location.search + window.location.hash;
// Retrieve scroll position from history state
const scroll = e.state?.scroll || null;
// TODO: Form Data Restoration
// Retrieve form data from history state and restore after action renders
// Implementation notes:
// - Get form_data from e.state?.form_data
// - After action on_ready(), find all form inputs
// - Restore values from form_data object
// - Trigger change events for restored fields
// - Handle edge cases:
// * Dynamic forms (loaded via Ajax) - need to wait for form to exist
// * File inputs (cannot be programmatically set for security)
// * Custom components (need vals() method for restoration)
// * Timing (must restore after form renders, possibly in on_ready)
// const form_data = e.state?.form_data || {};
// Dispatch without modifying history (we're already at the target URL)
Spa.dispatch(url, {
history: 'none',
scroll: scroll
});
});
// Intercept link clicks using event delegation
document.addEventListener('click', (e) => {
// Find <a> tag in event path (handles clicks on child elements)
let link = e.target.closest('a');
if (!link) {
return;
}
const href = link.getAttribute('href');
// Ignore if:
// - No href
// - Ctrl/Cmd/Meta is pressed (open in new tab)
// - Has target attribute
// - Not left click (button 0)
// - Is empty or hash-only (#)
if (!href ||
e.ctrlKey ||
e.metaKey ||
link.getAttribute('target') ||
e.button !== 0 ||
href === '' ||
href === '#') {
return;
}
// Parse URLs for comparison
const current_parsed = Spa.parse_url(window.location.href);
const target_parsed = Spa.parse_url(href);
// If same page (same path + search), let browser handle (causes reload)
// This mimics non-SPA behavior where clicking current page refreshes
if (current_parsed.path === target_parsed.path &&
current_parsed.search === target_parsed.search) {
console_debug('Spa', 'Same page click, letting browser reload');
return;
}
// Only intercept same-domain links
if (current_parsed.host !== target_parsed.host) {
console_debug('Spa', 'External domain link, letting browser handle: ' + href);
return;
}
// Check if target URL matches a Spa route
if (Spa.match_url_to_route(href)) {
console_debug('Spa', 'Intercepting link click: ' + href);
e.preventDefault();
Spa.dispatch(href, { history: 'auto' });
} else {
console_debug('Spa', 'No SPA route match, letting server handle: ' + href);
}
});
}
/**
* Main dispatch method - navigate to a URL
*
* This is the single entry point for all navigation within the SPA.
* Handles route matching, history management, layout/action lifecycle, and scroll restoration.
*
* @param {string} url - Target URL (relative or absolute)
* @param {object} options - Navigation options
* @param {string} options.history - 'auto'|'push'|'replace'|'none' (default: 'auto')
* - 'auto': Push for new URLs, replace for same URL
* - 'push': Always create new history entry
* - 'replace': Replace current history entry
* - 'none': Don't modify history (used for back/forward)
* @param {object|null} options.scroll - Scroll position {x, y} to restore (default: null = scroll to top)
* @param {boolean} options.triggers - Fire before/after dispatch events (default: true)
*/
static async dispatch(url, options = {}) {
if (Spa.is_dispatching) {
console.warn('Spa: Already dispatching, ignoring nested dispatch');
return;
}
Spa.is_dispatching = true;
try {
const opts = {
history: options.history || 'auto',
scroll: options.scroll || null,
triggers: options.triggers !== false,
};
console_debug('Spa', 'Dispatching to: ' + url + ' (history: ' + opts.history + ')');
// Handle fully qualified URLs
const current_domain = window.location.hostname;
if (url.startsWith('http://') || url.startsWith('https://')) {
try {
const parsed_url = new URL(url);
// Check if different domain
if (parsed_url.hostname !== current_domain) {
// External domain - navigate away
console_debug('Spa', 'External domain, navigating: ' + url);
console.warn('[Spa.dispatch] Executing document.location.href (external domain)', {
url: url,
reason: 'External domain'
});
document.location.href = url;
Spa.is_dispatching = false;
return;
}
// Same domain - strip to relative URL
url = parsed_url.pathname + parsed_url.search + parsed_url.hash;
console_debug('Spa', 'Same domain, stripped to relative: ' + url);
} catch (e) {
console.error('Spa: Invalid URL format:', url);
Spa.is_dispatching = false;
return;
}
}
// Parse the (now relative) URL
const parsed = Spa.parse_url(url);
// CRITICAL: Strip hash from URL before route matching
// Hash represents page state (e.g., DataGrid page number), not routing state
// Hash is preserved in browser URL bar but not used for route matching
const url_without_hash = parsed.path + parsed.search;
console_debug('Spa', 'URL for route matching (hash stripped): ' + url_without_hash);
// Try to match URL to a route (without hash)
const route_match = Spa.match_url_to_route(url_without_hash);
// Check if this is the same URL we're currently on (without hash)
const current_url = window.location.pathname + window.location.search;
const is_same_url = url_without_hash === current_url;
// Same URL navigation with history: 'auto' should reload via server
// This mimics non-SPA behavior where clicking current page refreshes
if (is_same_url && opts.history === 'auto') {
console_debug('Spa', 'Same URL with auto history, letting browser reload');
console.warn('[Spa.dispatch] Executing document.location.href (same URL reload)', {
url: url,
reason: 'Same URL with auto history - mimics browser reload behavior'
});
document.location.href = url;
Spa.is_dispatching = false;
return;
}
if (is_same_url && !route_match) {
// We're being asked to navigate to the current URL, but it doesn't match
// any known route. This shouldn't happen - prevents infinite redirect loop.
Spa.spa_unknown_route_fatal(parsed.path);
Spa.is_dispatching = false;
return;
}
// If no route match and we're not on this URL, let server handle it
if (!route_match) {
console_debug('Spa', 'No route matched, letting server handle: ' + url);
console.warn('[Spa.dispatch] Executing document.location.href (no route match)', {
url: url,
reason: 'URL does not match any registered SPA routes'
});
document.location.href = url;
Spa.is_dispatching = false;
return;
}
console_debug('Spa', 'Route match:', {
action_class: route_match?.action_class?.name,
args: route_match?.args,
layout: route_match?.layout,
});
// Check if action's @spa() attribute matches current SPA bootstrap
const action_spa_controller = route_match.action_class._spa_controller_method;
const current_spa_controller = window.rsxapp.current_controller + '::' + window.rsxapp.current_action;
if (action_spa_controller && action_spa_controller !== current_spa_controller) {
// Different SPA module - let server bootstrap it
console_debug('Spa', 'Different SPA module, letting server handle: ' + url);
console_debug('Spa', ` Action uses: ${action_spa_controller}`);
console_debug('Spa', ` Current SPA: ${current_spa_controller}`);
console.warn('[Spa.dispatch] Executing document.location.href (different SPA module)', {
url: url,
reason: 'Action belongs to different SPA module/bundle',
action_spa_controller: action_spa_controller,
current_spa_controller: current_spa_controller
});
document.location.href = url;
Spa.is_dispatching = false;
return;
}
// Update browser history with scroll position storage
if (opts.history !== 'none') {
// Store current scroll position before navigation
const current_scroll = {
x: window.scrollX || window.pageXOffset,
y: window.scrollY || window.pageYOffset
};
// Build history state object
const state = {
scroll: current_scroll,
form_data: {} // Reserved for future form state restoration
};
// Construct full URL with hash (hash is preserved in browser URL bar)
const new_url = parsed.path + parsed.search + (parsed.hash || '');
if (opts.history === 'push' || (opts.history === 'auto' && !is_same_url)) {
console_debug('Spa', 'Pushing history state');
history.pushState(state, '', new_url);
} else if (opts.history === 'replace' || (opts.history === 'auto' && is_same_url)) {
console_debug('Spa', 'Replacing history state');
history.replaceState(state, '', new_url);
}
}
// Set global Spa state
Spa.route = route_match;
Spa.path = parsed.path;
Spa.params = route_match.args;
// Get layout name and action info
const layout_name = route_match.layout;
const action_class = route_match.action_class;
const action_name = action_class.name;
// Log successful SPA navigation
console.warn('[Spa.dispatch] Executing SPA navigation', {
url: url,
path: parsed.path,
params: route_match.args,
action: action_name,
layout: layout_name,
history_mode: opts.history
});
// Check if we need a new layout
if (!Spa.layout || Spa.layout.constructor.name !== layout_name) {
// Stop old layout if exists (auto-stops children)
if (Spa.layout) {
await Spa.layout.trigger('unload');
Spa.layout.stop();
}
// Clear body and create new layout
$('body').empty();
$('body').attr('class', '');
// Create layout using component system
Spa.layout = $('body').component(layout_name, {}).component();
// Wait for layout to be ready
await Spa.layout.ready();
console_debug('Spa', `Created layout: ${layout_name}`);
} else {
// Wait for layout to finish previous action if still loading
await Spa.layout.ready();
}
// Tell layout to run the action
Spa.layout._set_action(action_name, route_match.args, url);
await Spa.layout._run_action();
// Scroll Restoration #1: Immediate (after action starts)
// This occurs synchronously after the action component is created
// May fail if page height is insufficient - that's okay, we'll retry later
if (opts.scroll) {
console_debug('Spa', 'Restoring scroll position (immediate): ' + opts.scroll.x + ', ' + opts.scroll.y);
window.scrollTo(opts.scroll.x, opts.scroll.y);
} else if (opts.scroll === undefined) {
// Default: scroll to top for new navigation (only if scroll not explicitly set)
console_debug('Spa', 'Scrolling to top (new navigation)');
window.scrollTo(0, 0);
}
// If opts.scroll === null, don't scroll (let Navigation API or browser handle it)
// TODO: Scroll Restoration #2 - After action on_ready() completes
// This requires an action lifecycle event system to detect when on_ready() finishes.
// Implementation notes:
// - Listen for action on_ready completion event
// - Only retry scroll if restoration #1 failed (page wasn't tall enough)
// - Check if page height has increased since first attempt
// - Avoid infinite retry loops (track attempts)
// - This ensures scroll restoration works even when content loads asynchronously
//
// Additional context: The action may load data in on_load() that increases page height,
// making the target scroll position accessible. The first restoration happens before
// this content renders, so we need a second attempt after the page is fully ready.
console_debug('Spa', `Rendered ${action_name} in ${layout_name}`);
} catch (error) {
console.error('[Spa] Dispatch error:', error);
// TODO: Better error handling - show error UI to user
throw error;
} finally {
Spa.is_dispatching = false;
}
}
/**
* Fatal error when trying to navigate to unknown route on current URL
* This shouldn't happen - prevents infinite redirect loops
*/
static spa_unknown_route_fatal(path) {
console.error(`Unknown route for path ${path} - this shouldn't happen`);
}
}

View File

@@ -0,0 +1,81 @@
/**
* Spa_Action - Base class for Spa action components
*
* An Action represents a page/route in the Spa. Each action class defines:
* - Route pattern(s) via @route() decorator
* - Layout to render within via @layout() decorator
* - Associated PHP controller via @spa() decorator
*
* Actions receive URL parameters in this.args and load data in on_load().
*
* Example:
* @route('/contacts')
* @layout('Frontend_Layout')
* @spa('Frontend_Contacts_Controller::index')
* class Contacts_Index_Action extends Spa_Action {
* async on_load() {
* this.data.contacts = await Contacts_Controller.fetch_all();
* }
* }
*/
class Spa_Action extends Component {
// constructor(args = {}, options = {}) {
// super(args, options);
// }
/**
* Called during load phase to fetch data from server
* Set this.data properties here
*/
async on_load() {
// Override in subclass
}
/**
* Generate URL for this action class with given parameters
* Static method for use without an instance
*/
static url(params = {}) {
const that = this;
// Get routes from decorator metadata
const routes = that._spa_routes || [];
if (routes.length === 0) {
console.error(`Action ${that.name} has no routes defined`);
return '#';
}
// Use first route as the pattern
// TODO: Implement smart route selection based on params
const pattern = routes[0];
return Spa.generate_url_from_pattern(pattern, params);
}
/**
* Navigate to this action with given parameters
* Static method for programmatic navigation
*/
static dispatch(params = {}) {
const that = this;
const url = that.url(params);
Spa.dispatch(url);
}
/**
* Instance method: Generate URL with current args merged with new params
*/
url(params = {}) {
const merged_params = { ...this.args, ...params };
return this.constructor.url(merged_params);
}
/**
* Instance method: Navigate with current args merged with new params
*/
dispatch(params = {}) {
const url = this.url(params);
Spa.dispatch(url);
}
}

View File

@@ -0,0 +1,18 @@
@rsx_id('Spa_App')
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<meta content="ie=edge" http-equiv="X-UA-Compatible">
{{-- Bundle includes --}}
{!! Frontend_Bundle::render() !!}
</head>
<body class="{{ rsx_body_class() }}">
</body>
</html>

View File

@@ -0,0 +1,78 @@
/**
* Spa Decorator Functions
*
* These decorators are used on Spa Action classes to define their route patterns,
* layouts, and associated PHP controller methods.
*
* Decorators store metadata as static properties on the class, which Spa.js
* reads during initialization to register routes.
*/
/**
* @decorator
* Define route pattern(s) for this action
*
* Usage:
* @route('/contacts')
* @route('/contacts/index')
* class Contacts_Index_Action extends Spa_Action { }
*/
function route(pattern) {
return function (target) {
// Store route pattern on the class
if (!target._spa_routes) {
target._spa_routes = [];
}
target._spa_routes.push(pattern);
return target;
};
}
/**
* @decorator
* Define which layout this action renders within
*
* Usage:
* @layout('Frontend_Layout')
* class Contacts_Index_Action extends Spa_Action { }
*/
function layout(layout_name) {
return function (target) {
// Store layout name on the class
target._spa_layout = layout_name;
return target;
};
}
/**
* @decorator
* Link this Spa action to its PHP controller method
* Used for generating PHP URLs and understanding the relationship
*
* Usage:
* @spa('Frontend_Contacts_Controller::index')
* class Contacts_Index_Action extends Spa_Action { }
*/
function spa(controller_method) {
return function (target) {
// Store controller::method reference on the class
target._spa_controller_method = controller_method;
return target;
};
}
/**
* @decorator
* Define the browser page title for this action
*
* Usage:
* @title('Contacts - RSX')
* class Contacts_Index_Action extends Spa_Action { }
*/
function title(page_title) {
return function (target) {
// Store page title on the class
target._spa_title = page_title;
return target;
};
}

150
app/RSpade/Core/SPA/Spa_Layout.js Executable file
View File

@@ -0,0 +1,150 @@
/**
* Spa_Layout - Base class for Spa layouts
*
* Layouts provide the persistent wrapper (header, nav, footer) around actions.
* They render directly to body and contain a content area where actions render.
*
* Requirements:
* - Must have an element with $id="content" where actions will render
* - Persists across action navigations (only re-created when layout changes)
*
* Lifecycle events triggered for actions:
* - before_action_init, action_init
* - before_action_render, action_render
* - before_action_ready, action_ready
*
* Hook methods that can be overridden:
* - on_action(url, action_name, args) - Called when new action is set
*/
class Spa_Layout extends Component {
on_create() {
console.log('Layout create!');
}
/**
* Get the content container where actions render
* @returns {jQuery} The content element
*/
$content() {
return this.$id('content');
}
/**
* Set which action should be rendered
* Called by Spa.dispatch() - stores action info for _run_action()
*
* @param {string} action_name - Name of the action class
* @param {object} args - URL parameters and query params
* @param {string} url - The full URL being dispatched to
*/
_set_action(action_name, args, url) {
this._pending_action_name = action_name;
this._pending_action_args = args;
this._pending_action_url = url;
}
/**
* Execute the pending action - stop old action, create new one
* Called by Spa.dispatch() after _set_action()
*/
async _run_action() {
const action_name = this._pending_action_name;
const args = this._pending_action_args;
const url = this._pending_action_url;
// Get content container
console.log('[Spa_Layout] Looking for content element...');
console.log('[Spa_Layout] this.$id available?', typeof this.$id);
console.log('[Spa_Layout] this.$ exists?', !!this.$);
const $content = this.$content();
console.log('[Spa_Layout] $content result:', $content);
console.log('[Spa_Layout] $content.length:', $content?.length);
if (!$content || !$content.length) {
// TODO: Better error handling - show error UI instead of just console
console.error(`[Spa_Layout] Layout ${this.constructor.name} must have an element with $id="content"`);
console.error(
'[Spa_Layout] Available elements in this.$:',
this.$.find('[data-id]')
.toArray()
.map((el) => el.getAttribute('data-id'))
);
throw new Error(`Layout ${this.constructor.name} must have an element with $id="content"`);
}
// Stop old action (jqhtml auto-stops when .component() replaces)
// Clear content area
$content.empty();
// Get the action class to check for @title decorator
const action_class = Manifest.get_class_by_name(action_name);
// Update page title if @title decorator is present (optional), clear if not
if (action_class._spa_title) {
document.title = action_class._spa_title;
} else {
document.title = '';
}
// Create new action component
console.log('[Spa_Layout] Creating action:', action_name, 'with args:', args);
console.log('[Spa_Layout] Args keys:', Object.keys(args || {}));
const action = $content.component(action_name, args).component();
// Store reference
Spa.action = action;
this.action = action;
// Call on_action hook (can be overridden by subclasses)
this.on_action(url, action_name, args);
this.trigger('action');
// Setup event forwarding from action to layout
// Action triggers 'init' -> Layout triggers 'action_init'
this._setup_action_events(action);
// Wait for action to be ready
await action.ready();
}
/**
* Setup event listeners on action to forward to layout
* @private
*/
_setup_action_events(action) {
const events = ['before_init', 'init', 'before_render', 'render', 'before_ready', 'ready'];
events.forEach((event) => {
action.on(event, () => {
// Trigger corresponding layout event with 'action_' prefix
const layout_event = event.replace('before_', 'before_action_').replace(/^(?!before)/, 'action_');
this.trigger(layout_event, action);
});
});
}
/**
* Hook called when a new action is set
* Override this in subclasses to react to action changes.
*
* on_action can / should be implemented as a async function. but nothing is waiting for it, this is
* just defined this way for convienence to make clear that async code can be used and is appropriate
* for on_action.
*
* If it is necessary to wait for the action itself to reach ready state, add:
* await this.action.ready();
* to the concrete implementation of on_action.
*
* @param {string} url - The URL being navigated to
* @param {string} action_name - Name of the action class
* @param {object} args - URL parameters and query params
*/
async on_action(url, action_name, args) {
// Empty by default - override in subclass
}
on_ready() {
console.log('layout ready!');
}
}

View File

@@ -0,0 +1,180 @@
<?php
namespace App\RSpade\Core\SPA;
use RuntimeException;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Manifest\ManifestSupport_Abstract;
/**
* Support module for extracting Spa route metadata from Spa_Action classes
* This runs after the primary manifest is built to add Spa routes to the unified routes index
*/
class Spa_ManifestSupport extends ManifestSupport_Abstract
{
/**
* Get the name of this support module
*
* @return string
*/
public static function get_name(): string
{
return 'Spa Routes';
}
/**
* Process the manifest and build Spa routes index
*
* @param array &$manifest_data Reference to the manifest data array
* @return void
*/
public static function process(array &$manifest_data): void
{
// Initialize routes key if not already set
if (!isset($manifest_data['data']['routes'])) {
$manifest_data['data']['routes'] = [];
}
// Get all files to look up PHP controller metadata
$files = $manifest_data['data']['files'];
// Get all JavaScript classes extending Spa_Action
$action_classes = Manifest::js_get_extending('Spa_Action');
foreach ($action_classes as $class_name => $action_metadata) {
// Extract decorator metadata
$decorators = $action_metadata['decorators'] ?? [];
// Parse decorators into route configuration
$route_info = static::_parse_decorators($decorators);
// Skip if no route decorator found
if (empty($route_info['routes'])) {
continue;
}
// Validate that @spa decorator is present
if (empty($route_info['spa_controller']) || empty($route_info['spa_method'])) {
throw new RuntimeException(
"Spa action '{$class_name}' is missing required @spa decorator.\n" .
"Add @spa('Controller_Class::method') to specify the PHP controller method that serves the Spa bootstrap.\n" .
"File: {$action_metadata['file']}"
);
}
// Find the PHP controller file and metadata
$php_controller_class = $route_info['spa_controller'];
$php_controller_method = $route_info['spa_method'];
$php_controller_file = null;
$php_controller_fqcn = null;
// Search for the controller in the manifest
foreach ($files as $file => $metadata) {
if (($metadata['class'] ?? null) === $php_controller_class || ($metadata['fqcn'] ?? null) === $php_controller_class) {
$php_controller_file = $file;
$php_controller_fqcn = $metadata['fqcn'] ?? $metadata['class'];
break;
}
}
if (!$php_controller_file) {
throw new RuntimeException(
"Spa action '{$class_name}' references unknown controller '{$php_controller_class}'.\n" .
"The @spa decorator must reference a valid PHP controller class.\n" .
"File: {$action_metadata['file']}"
);
}
// Extract Auth attributes from the PHP controller method
$require_attrs = [];
$file_metadata = $files[$php_controller_file] ?? null;
if ($file_metadata && isset($file_metadata['public_static_methods'][$php_controller_method]['attributes']['Auth'])) {
$require_attrs = $file_metadata['public_static_methods'][$php_controller_method]['attributes']['Auth'];
}
// Build complete route metadata for each route pattern
foreach ($route_info['routes'] as $route_pattern) {
// Ensure pattern starts with /
if ($route_pattern[0] !== '/') {
$route_pattern = '/' . $route_pattern;
}
// Check for duplicate route definition (pattern must be unique across all route types)
if (isset($manifest_data['data']['routes'][$route_pattern])) {
$existing = $manifest_data['data']['routes'][$route_pattern];
$existing_type = $existing['type'];
$existing_location = $existing_type === 'spa'
? "Spa action {$existing['js_action_class']} in {$existing['file']}"
: "{$existing['class']}::{$existing['method']} in {$existing['file']}";
throw new RuntimeException(
"Duplicate route definition: {$route_pattern}\n" .
" Already defined: {$existing_location}\n" .
" Conflicting: Spa action {$class_name} in {$action_metadata['file']}"
);
}
// Store route with unified structure
$manifest_data['data']['routes'][$route_pattern] = [
'methods' => ['GET'], // Spa routes are always GET
'type' => 'spa',
'class' => $php_controller_fqcn,
'method' => $php_controller_method,
'name' => null,
'file' => $php_controller_file,
'require' => $require_attrs,
'js_action_class' => $class_name,
];
}
}
}
/**
* Parse decorator metadata into route configuration
*
* @param array $decorators Array of decorator data from manifest
* @return array Parsed route configuration
*/
private static function _parse_decorators(array $decorators): array
{
$config = [
'routes' => [],
'layout' => null,
'spa_controller' => null,
'spa_method' => null,
];
foreach ($decorators as $decorator) {
[$name, $args] = $decorator;
switch ($name) {
case 'route':
// @route('/path') - args is array with single string
if (!empty($args[0])) {
$config['routes'][] = $args[0];
}
break;
case 'layout':
// @layout('Layout_Name') - args is array with single string
if (!empty($args[0])) {
$config['layout'] = $args[0];
}
break;
case 'spa':
// @spa('Controller::method') - args is array with single string
if (!empty($args[0])) {
$parts = explode('::', $args[0]);
if (count($parts) === 2) {
$config['spa_controller'] = $parts[0];
$config['spa_method'] = $parts[1];
}
}
break;
}
}
return $config;
}
}

View File

@@ -47,7 +47,7 @@ class Search_Index_Model extends Rsx_Site_Model_Abstract
*
* @var string
*/
protected $table = 'search_indexes';
protected $table = '_search_indexes';
/**
* The attributes that should be cast to native types

View File

@@ -7,6 +7,8 @@
namespace App\RSpade\Core\Service;
use App\RSpade\Core\Task\Task_Instance;
/**
* Base service class for all RSX services
*
@@ -21,10 +23,11 @@ abstract class Rsx_Service_Abstract
* Pre-task hook called before any task execution
* Override in child classes to add pre-task logic
*
* @param Task_Instance $task Task instance for logging and status tracking
* @param array $params Task parameters
* @return mixed|null Return null to continue, or throw exception to halt
*/
public static function pre_task(array $params = [])
public static function pre_task(Task_Instance $task, array $params = [])
{
// Default implementation does nothing
// Override in child classes to add authentication, validation, logging, etc.

View File

@@ -0,0 +1,64 @@
<?php
namespace App\RSpade\Core\Session;
use Illuminate\Support\Facades\DB;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
use App\RSpade\Core\Task\Task_Instance;
/**
* Session_Cleanup_Service
*
* Scheduled cleanup of expired and abandoned sessions.
*
* Cleanup Rules:
* - Logged-in sessions (login_user_id set): Delete if older than 365 days
* - Anonymous sessions (login_user_id null): Delete if older than 14 days
*
* Runs daily at 3 AM via scheduled task.
*/
class Session_Cleanup_Service extends Rsx_Service_Abstract
{
/**
* Clean up expired and abandoned sessions
*
* Deletes sessions based on age and login status:
* - Logged-in sessions: 365 days retention
* - Anonymous sessions: 14 days retention
*
* @param Task_Instance $task Task instance for logging
* @param array $params Task parameters
* @return array Cleanup statistics
*/
#[Task('Clean up expired and abandoned sessions (runs daily at 3 AM)')]
#[Schedule('0 3 * * *')]
public static function cleanup_sessions(Task_Instance $task, array $params = [])
{
// Logged-in sessions: older than 365 days
$logged_in_cutoff = now()->subDays(365);
$logged_in_deleted = DB::table('sessions')
->whereNotNull('login_user_id')
->where('last_active', '<', $logged_in_cutoff)
->delete();
$task->info("Deleted {$logged_in_deleted} logged-in sessions older than 365 days");
// Anonymous sessions: older than 14 days
$anonymous_cutoff = now()->subDays(14);
$anonymous_deleted = DB::table('sessions')
->whereNull('login_user_id')
->where('last_active', '<', $anonymous_cutoff)
->delete();
$task->info("Deleted {$anonymous_deleted} anonymous sessions older than 14 days");
$total_deleted = $logged_in_deleted + $anonymous_deleted;
$task->info("Total sessions deleted: {$total_deleted}");
return [
'logged_in_deleted' => $logged_in_deleted,
'anonymous_deleted' => $anonymous_deleted,
'total_deleted' => $total_deleted,
];
}
}

295
app/RSpade/Core/Task/CLAUDE.md Executable file
View File

@@ -0,0 +1,295 @@
# Task System
RSpade provides a unified task execution system supporting immediate CLI execution, queued async tasks, and scheduled cron-based tasks.
## Creating Task Services
Task services must extend `Rsx_Service_Abstract` and use attributes to define tasks:
```php
use App\RSpade\Core\Service\Rsx_Service_Abstract;
use App\RSpade\Core\Task\Task_Instance;
class My_Service extends Rsx_Service_Abstract
{
// Simple task (can be run via CLI or dispatched to queue)
#[Task('Description of what this task does')]
public static function my_task(Task_Instance $task, array $params = [])
{
$task->info('Task is running');
// Task logic here
return ['result' => 'success'];
}
// Scheduled task (runs automatically via cron)
#[Task('Cleanup old records')]
#[Schedule('0 2 * * *')] // Daily at 2 AM
public static function cleanup(Task_Instance $task, array $params = [])
{
$task->info('Cleanup running');
// Cleanup logic here
}
}
```
## Requirements
- Class MUST extend `Rsx_Service_Abstract`
- Method MUST be `public static`
- Method MUST have signature: `(Task_Instance $task, array $params = [])`
- Method MUST have `#[Task('description')]` attribute
- Scheduled tasks also need `#[Schedule('cron_expression')]` attribute
## Important Notes on Attributes
- Attributes work via reflection - NO backing PHP classes needed
- The linter will remove `use` statements for attributes - this is INTENTIONAL and CORRECT
- Use `#[Task]` and `#[Schedule]`, NOT `#[Task_Attribute]` or any other variation
- Never create PHP classes for these attributes
## Task_Instance Methods
The `$task` parameter provides logging and progress tracking:
```php
public static function process_items(Task_Instance $task, array $params = [])
{
$items = Item_Model::all();
$total = count($items);
$task->info("Processing {$total} items");
foreach ($items as $index => $item) {
// Update progress
$task->progress($index + 1, $total);
// Log info
$task->info("Processing item {$item->id}");
// Log warnings
if ($item->status === 'pending') {
$task->warning("Item {$item->id} is still pending");
}
// Process item
$item->process();
}
$task->info("Completed processing {$total} items");
return ['processed' => $total];
}
```
## Running Tasks
### CLI Execution
```bash
# List all available tasks
php artisan rsx:task:list
# Run task immediately from CLI (synchronous)
php artisan rsx:task:run Service_Class method_name
# Run with parameters
php artisan rsx:task:run Service_Class method_name '{"param":"value"}'
```
### Programmatic Dispatch
```php
// Dispatch task to queue (async)
use App\RSpade\Core\Task\Task;
Task::dispatch('Service_Class', 'method_name', ['param' => 'value']);
// Dispatch with delay
Task::dispatch('Service_Class', 'method_name', ['param' => 'value'], 60); // 60 second delay
```
### Scheduled Execution
Tasks with `#[Schedule]` attribute run automatically via cron.
Add to crontab:
```cron
* * * * * cd /var/www/html && php artisan rsx:task:process
```
This runs every minute and:
1. Processes any queued tasks
2. Runs scheduled tasks that are due
## Cron Expression Syntax
```
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday)
│ │ │ │ │
│ │ │ │ │
* * * * *
```
Common patterns:
- `*/5 * * * *` - Every 5 minutes
- `*/30 * * * *` - Every 30 minutes
- `0 * * * *` - Every hour
- `0 2 * * *` - Daily at 2 AM
- `0 */6 * * *` - Every 6 hours
- `0 0 * * 0` - Weekly on Sunday at midnight
- `0 0 1 * *` - Monthly on the 1st at midnight
- `30 2 * * 1-5` - Weekdays at 2:30 AM
## Task Queue
Tasks dispatched via `Task::dispatch()` are stored in the database queue:
```sql
task_queue
├── id (bigint)
├── service_class (varchar)
├── method_name (varchar)
├── parameters (json)
├── status (enum: pending, processing, completed, failed)
├── attempts (int)
├── run_at (timestamp)
├── started_at (timestamp)
├── completed_at (timestamp)
├── error_message (text)
└── timestamps
```
## Error Handling
Tasks that throw exceptions:
1. Are marked as `failed` in the queue
2. Error message is logged
3. Can be retried manually via CLI
4. Maximum 3 automatic retry attempts
```php
public static function risky_task(Task_Instance $task, array $params = [])
{
try {
// Risky operation
$result = External_API::call();
} catch (Exception $e) {
$task->error("API call failed: " . $e->getMessage());
throw $e; // Re-throw to mark task as failed
}
return $result;
}
```
## Best Practices
1. **Keep tasks idempotent** - Safe to run multiple times
2. **Log progress** for long-running tasks
3. **Return meaningful data** for debugging
4. **Use transactions** for database operations
5. **Set appropriate schedules** to avoid overlap
6. **Handle exceptions gracefully**
7. **Keep tasks focused** - one task, one purpose
## Common Task Patterns
### Cleanup Task
```php
#[Task('Clean old logs')]
#[Schedule('0 3 * * *')] // Daily at 3 AM
public static function cleanup_logs(Task_Instance $task, array $params = [])
{
$cutoff = now()->subDays(30);
$deleted = Log_Model::where('created_at', '<', $cutoff)->delete();
$task->info("Deleted {$deleted} old log entries");
return ['deleted' => $deleted];
}
```
### Import Task
```php
#[Task('Import data from CSV')]
public static function import_csv(Task_Instance $task, array $params = [])
{
$file_path = $params['file_path'] ?? null;
if (!$file_path || !file_exists($file_path)) {
throw new Exception("File not found: {$file_path}");
}
$handle = fopen($file_path, 'r');
$row_count = 0;
while (($data = fgetcsv($handle)) !== false) {
$row_count++;
$task->progress($row_count);
// Process row
Model::create([
'field1' => $data[0],
'field2' => $data[1],
]);
}
fclose($handle);
$task->info("Imported {$row_count} rows");
return ['imported' => $row_count];
}
```
### Report Generation
```php
#[Task('Generate monthly report')]
#[Schedule('0 0 1 * *')] // First day of month at midnight
public static function monthly_report(Task_Instance $task, array $params = [])
{
$start = now()->subMonth()->startOfMonth();
$end = now()->subMonth()->endOfMonth();
$task->info("Generating report for {$start->format('F Y')}");
$data = Sales_Model::whereBetween('created_at', [$start, $end])
->get();
$report = Report_Generator::create($data);
$report->save(storage_path("reports/monthly-{$start->format('Y-m')}.pdf"));
$task->info("Report generated successfully");
return [
'period' => $start->format('F Y'),
'records' => count($data),
];
}
```
## Monitoring Tasks
View task history and status:
```bash
# View pending tasks
php artisan rsx:task:pending
# View failed tasks
php artisan rsx:task:failed
# Retry failed task
php artisan rsx:task:retry {task_id}
# Clear completed tasks older than 30 days
php artisan rsx:task:clear --days=30
```
## Service Discovery
The manifest system automatically discovers all services extending `Rsx_Service_Abstract` in directories configured for scanning. Ensure your service directory is included in the manifest's `scan_directories` configuration.

View File

@@ -0,0 +1,202 @@
<?php
namespace App\RSpade\Core\Task;
use DateTime;
use DateTimeZone;
/**
* Cron_Parser
*
* Parses cron expressions and calculates next run times.
* Supports standard cron syntax: minute hour day month weekday
*
* Examples:
* Every minute: "* * * * *"
* Every hour at minute 0: "0 * * * *"
* Daily at midnight: "0 0 * * *"
* Daily at 3 AM: "0 3 * * *"
* Weekly on Sunday at midnight: "0 0 * * 0"
* Monthly on the 1st at midnight: "0 0 1 * *"
*/
#[Instantiatable]
class Cron_Parser
{
private array $parts;
private string $expression;
/**
* Parse a cron expression
*
* @param string $expression Cron expression (minute hour day month weekday)
* @throws \InvalidArgumentException If expression is invalid
*/
public function __construct(string $expression)
{
$this->expression = $expression;
$this->parts = $this->parse($expression);
}
/**
* Parse cron expression into components
*
* @param string $expression
* @return array ['minute' => [...], 'hour' => [...], 'day' => [...], 'month' => [...], 'weekday' => [...]]
* @throws \InvalidArgumentException
*/
private function parse(string $expression): array
{
$parts = preg_split('/\s+/', trim($expression));
if (count($parts) !== 5) {
throw new \InvalidArgumentException("Invalid cron expression: {$expression}. Expected 5 parts (minute hour day month weekday)");
}
return [
'minute' => $this->parse_field($parts[0], 0, 59),
'hour' => $this->parse_field($parts[1], 0, 23),
'day' => $this->parse_field($parts[2], 1, 31),
'month' => $this->parse_field($parts[3], 1, 12),
'weekday' => $this->parse_field($parts[4], 0, 6), // 0 = Sunday
];
}
/**
* Parse a single cron field
*
* Supports: asterisk, numbers, step values, ranges, lists
*
* @param string $field Field value from cron expression
* @param int $min Minimum allowed value
* @param int $max Maximum allowed value
* @return array Array of allowed values
*/
private function parse_field(string $field, int $min, int $max): array
{
// Asterisk means all values
if ($field === '*') {
return range($min, $max);
}
// Step values (e.g., */15)
if (preg_match('/^\*\/(\d+)$/', $field, $matches)) {
$step = (int) $matches[1];
return range($min, $max, $step);
}
// Range (e.g., 1-5)
if (preg_match('/^(\d+)-(\d+)$/', $field, $matches)) {
$start = (int) $matches[1];
$end = (int) $matches[2];
if ($start < $min || $end > $max || $start > $end) {
throw new \InvalidArgumentException("Invalid range in cron expression: {$field}");
}
return range($start, $end);
}
// List (e.g., 1,3,5)
if (str_contains($field, ',')) {
$values = array_map('intval', explode(',', $field));
foreach ($values as $value) {
if ($value < $min || $value > $max) {
throw new \InvalidArgumentException("Invalid value in cron expression: {$value}");
}
}
return $values;
}
// Single value
$value = (int) $field;
if ($value < $min || $value > $max) {
throw new \InvalidArgumentException("Invalid value in cron expression: {$value}");
}
return [$value];
}
/**
* Calculate next run time from a given timestamp
*
* @param int|null $from_timestamp Start time (default: current time)
* @return int Next run timestamp
*/
public function get_next_run_time(?int $from_timestamp = null): int
{
if ($from_timestamp === null) {
$from_timestamp = time();
}
// Start from the next minute (cron runs at most once per minute)
$current = new DateTime('@' . $from_timestamp);
$current->setTimezone(new DateTimeZone(date_default_timezone_get()));
$current->modify('+1 minute');
$current->setTime((int) $current->format('H'), (int) $current->format('i'), 0);
// Try up to 4 years in the future (should be more than enough)
$max_iterations = 60 * 24 * 365 * 4;
$iterations = 0;
while ($iterations < $max_iterations) {
if ($this->matches($current)) {
return $current->getTimestamp();
}
$current->modify('+1 minute');
$iterations++;
}
throw new \RuntimeException("Could not calculate next run time for cron expression: {$this->expression}");
}
/**
* Check if a datetime matches the cron expression
*
* @param DateTime $datetime
* @return bool
*/
private function matches(DateTime $datetime): bool
{
$minute = (int) $datetime->format('i');
$hour = (int) $datetime->format('H');
$day = (int) $datetime->format('d');
$month = (int) $datetime->format('n');
$weekday = (int) $datetime->format('w');
return in_array($minute, $this->parts['minute'])
&& in_array($hour, $this->parts['hour'])
&& in_array($day, $this->parts['day'])
&& in_array($month, $this->parts['month'])
&& in_array($weekday, $this->parts['weekday']);
}
/**
* Validate a cron expression without creating an instance
*
* @param string $expression
* @return bool
*/
public static function is_valid(string $expression): bool
{
try {
new self($expression);
return true;
} catch (\InvalidArgumentException $e) {
return false;
}
}
/**
* Get the original cron expression
*
* @return string
*/
public function get_expression(): string
{
return $this->expression;
}
}

View File

@@ -8,8 +8,11 @@
namespace App\RSpade\Core\Task;
use Exception;
use Illuminate\Support\Facades\DB;
use App\RSpade\Core\Manifest\Manifest;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
use App\RSpade\Core\Task\Task_Instance;
use App\RSpade\Core\Task\Task_Status;
/**
* Task - Unified task execution system
@@ -92,23 +95,45 @@ class Task
throw new Exception("Method {$rsx_task} in service {$service_class} must have #[Task] attribute");
}
// Call pre_task() if exists
if (method_exists($service_class, 'pre_task')) {
$pre_result = $service_class::pre_task($params);
if ($pre_result !== null) {
// pre_task returned something, use that as response
return $pre_result;
// Create task instance for immediate execution
$task_instance = new Task_Instance(
$service_class,
$rsx_task,
$params,
'default',
true // immediate execution
);
// Mark as started
$task_instance->mark_started();
try {
// Call pre_task() if exists
if (method_exists($service_class, 'pre_task')) {
$pre_result = $service_class::pre_task($task_instance, $params);
if ($pre_result !== null) {
// pre_task returned something, use that as response
$task_instance->mark_completed($pre_result);
return $pre_result;
}
}
// Call the actual task method
$response = $service_class::$rsx_task($task_instance, $params);
// Mark as completed
$task_instance->mark_completed($response);
// Filter response through JSON encode/decode to remove PHP objects
// (similar to Ajax behavior)
$filtered_response = json_decode(json_encode($response), true);
return $filtered_response;
} catch (Exception $e) {
// Mark as failed
$task_instance->mark_failed($e->getMessage());
throw $e;
}
// Call the actual task method
$response = $service_class::$rsx_task($params);
// Filter response through JSON encode/decode to remove PHP objects
// (similar to Ajax behavior)
$filtered_response = json_decode(json_encode($response), true);
return $filtered_response;
}
/**
@@ -125,4 +150,195 @@ class Task
'result' => $response,
];
}
/**
* Dispatch a task to the queue for async execution
*
* Creates a database record for the task and returns the task ID.
* Task will be picked up and executed by the task processor (rsx:task:process).
*
* @param string $rsx_service Service name (e.g., 'Seeder_Service')
* @param string $rsx_task Task/method name (e.g., 'seed_clients')
* @param array $params Parameters to pass to the task
* @param array $options Optional task options:
* - 'queue' => Queue name (default: 'default')
* - 'scheduled_for' => Timestamp when task should run (default: now)
* - 'timeout' => Maximum execution time in seconds (default: from config)
* @return int Task ID
* @throws Exception
*/
public static function dispatch(string $rsx_service, string $rsx_task, array $params = [], array $options = []): int
{
// Get manifest to find service
$manifest = Manifest::get_all();
$service_class = null;
$file_info = null;
// Search for service class in manifest
foreach ($manifest as $file_path => $info) {
// Skip non-PHP files or files without classes
if (!isset($info['class']) || !isset($info['fqcn'])) {
continue;
}
// Check if class name matches exactly (without namespace)
$class_basename = basename(str_replace('\\', '/', $info['fqcn']));
if ($class_basename === $rsx_service) {
$service_class = $info['fqcn'];
$file_info = $info;
break;
}
}
if (!$service_class) {
throw new Exception("Service class not found: {$rsx_service}");
}
// Check if class exists
if (!class_exists($service_class)) {
throw new Exception("Service class does not exist: {$service_class}");
}
// Check if it's a subclass of Rsx_Service_Abstract
if (!Manifest::php_is_subclass_of($service_class, Rsx_Service_Abstract::class)) {
throw new Exception("Service {$service_class} must extend Rsx_Service_Abstract");
}
// Check if method exists and has Task attribute
if (!isset($file_info['public_static_methods'][$rsx_task])) {
throw new Exception("Task {$rsx_task} not found in service {$service_class}");
}
$method_info = $file_info['public_static_methods'][$rsx_task];
$has_task = false;
// Check for Task attribute in method metadata
if (isset($method_info['attributes'])) {
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
if ($attr_name === 'Task' || str_ends_with($attr_name, '\\Task')) {
$has_task = true;
break;
}
}
}
if (!$has_task) {
throw new Exception("Method {$rsx_task} in service {$service_class} must have #[Task] attribute");
}
// Create task instance
$instance = new Task_Instance(
$service_class,
$rsx_task,
$params,
$options['queue'] ?? 'default',
false // not immediate
);
// Create database record
$data = [
'class' => $service_class,
'method' => $rsx_task,
'queue' => $options['queue'] ?? 'default',
'status' => Task_Status::PENDING,
'params' => json_encode($params),
'scheduled_for' => $options['scheduled_for'] ?? now(),
'timeout' => $options['timeout'] ?? config('rsx.tasks.default_timeout'),
'created_at' => now(),
'updated_at' => now(),
];
$task_id = DB::table('_task_queue')->insertGetId($data);
return $task_id;
}
/**
* Get the status of a task
*
* Returns task information including status, logs, result, and error.
*
* @param int $task_id Task ID
* @return array|null Task status data or null if not found
*/
public static function status(int $task_id): ?array
{
$row = DB::table('_task_queue')->where('id', $task_id)->first();
if (!$row) {
return null;
}
return [
'id' => $row->id,
'class' => $row->class,
'method' => $row->method,
'queue' => $row->queue,
'status' => $row->status,
'params' => json_decode($row->params, true),
'result' => json_decode($row->result, true),
'logs' => $row->logs ? explode("\n", $row->logs) : [],
'error' => $row->error,
'scheduled_for' => $row->scheduled_for,
'started_at' => $row->started_at,
'completed_at' => $row->completed_at,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
];
}
/**
* Get all scheduled tasks from manifest
*
* Scans the manifest for methods with #[Schedule] attribute
* and returns information about each scheduled task.
*
* @return array Array of scheduled task definitions
*/
public static function get_scheduled_tasks(): array
{
$manifest = Manifest::get_all();
$scheduled_tasks = [];
foreach ($manifest as $file_path => $info) {
// Skip non-PHP files or files without classes
if (!isset($info['class']) || !isset($info['fqcn'])) {
continue;
}
// Check if it's a service class
if (!isset($info['public_static_methods'])) {
continue;
}
foreach ($info['public_static_methods'] as $method_name => $method_info) {
// Check for Schedule attribute
if (!isset($method_info['attributes'])) {
continue;
}
foreach ($method_info['attributes'] as $attr_name => $attr_instances) {
if ($attr_name === 'Schedule' || str_ends_with($attr_name, '\\Schedule')) {
// Found a scheduled task
foreach ($attr_instances as $attr_instance) {
$cron_expression = $attr_instance[0] ?? null;
$queue = $attr_instance[1] ?? 'scheduled';
if ($cron_expression) {
$scheduled_tasks[] = [
'class' => $info['fqcn'],
'method' => $method_name,
'cron_expression' => $cron_expression,
'queue' => $queue,
];
}
}
}
}
}
}
return $scheduled_tasks;
}
}

View File

@@ -0,0 +1,446 @@
<?php
namespace App\RSpade\Core\Task;
use Exception;
use Illuminate\Support\Facades\DB;
use App\RSpade\Core\Task\Task_Status;
/**
* Task_Instance
*
* Represents a single task execution instance with logging, status tracking,
* and temp directory management. Passed to all task methods for tracking.
*/
#[Instantiatable]
class Task_Instance
{
private ?int $id;
private string $class;
private string $method;
private string $queue;
private array $params;
private Task_Status $status;
private array $logs = [];
private ?string $temp_dir = null;
private bool $is_immediate;
/**
* Create a new task instance
*
* @param string $class Fully qualified class name
* @param string $method Static method name
* @param array $params Task parameters
* @param string $queue Queue name
* @param bool $is_immediate True for immediate execution, false for database-backed
*/
public function __construct(
string $class,
string $method,
array $params = [],
string $queue = 'default',
bool $is_immediate = true
) {
$this->class = $class;
$this->method = $method;
$this->params = $params;
$this->queue = $queue;
$this->is_immediate = $is_immediate;
$this->status = new Task_Status(Task_Status::PENDING);
$this->id = null;
}
/**
* Load task instance from database by ID
*
* @param int $id Task ID
* @return self|null
*/
public static function find(int $id): ?self
{
$row = DB::table('_task_queue')->where('id', $id)->first();
if (!$row) {
return null;
}
$instance = new self(
$row->class,
$row->method,
json_decode($row->params, true) ?? [],
$row->queue,
false
);
$instance->id = $row->id;
$instance->status = new Task_Status($row->status);
$instance->logs = $row->logs ? explode("\n", $row->logs) : [];
return $instance;
}
/**
* Save task instance to database
*
* @return int Task ID
*/
public function save(): int
{
if ($this->is_immediate) {
throw new Exception("Cannot save immediate task to database");
}
$data = [
'class' => $this->class,
'method' => $this->method,
'queue' => $this->queue,
'status' => $this->status->value(),
'params' => json_encode($this->params),
'logs' => implode("\n", $this->logs),
'updated_at' => now(),
];
if ($this->id === null) {
$data['created_at'] = now();
$this->id = DB::table('_task_queue')->insertGetId($data);
} else {
DB::table('_task_queue')->where('id', $this->id)->update($data);
}
return $this->id;
}
/**
* Update task status in database
*
* @param Task_Status $status New status
*/
public function update_status(Task_Status $status): void
{
$this->status = $status;
if (!$this->is_immediate && $this->id !== null) {
DB::table('_task_queue')
->where('id', $this->id)
->update(['status' => $status->value(), 'updated_at' => now()]);
}
}
/**
* Mark task as started
*/
public function mark_started(): void
{
$this->update_status(new Task_Status(Task_Status::RUNNING));
if (!$this->is_immediate && $this->id !== null) {
DB::table('_task_queue')
->where('id', $this->id)
->update([
'started_at' => now(),
'worker_pid' => getmypid(),
'updated_at' => now(),
]);
}
}
/**
* Mark task as completed
*
* @param mixed $result Optional result data
*/
public function mark_completed($result = null): void
{
$this->update_status(new Task_Status(Task_Status::COMPLETED));
if (!$this->is_immediate && $this->id !== null) {
$update = [
'completed_at' => now(),
'updated_at' => now(),
];
if ($result !== null) {
$update['result'] = json_encode($result);
}
DB::table('_task_queue')->where('id', $this->id)->update($update);
}
$this->cleanup_temp_dir();
}
/**
* Mark task as failed
*
* @param string $error Error message
*/
public function mark_failed(string $error): void
{
$this->update_status(new Task_Status(Task_Status::FAILED));
$this->error("Task failed: {$error}");
if (!$this->is_immediate && $this->id !== null) {
DB::table('_task_queue')
->where('id', $this->id)
->update([
'error' => $error,
'completed_at' => now(),
'updated_at' => now(),
]);
}
$this->cleanup_temp_dir();
}
/**
* Add a log message
*
* @param string $level Log level (info, error, debug)
* @param string $message Log message
*/
public function log(string $level, string $message): void
{
$timestamp = date('Y-m-d H:i:s');
$log_line = "[{$timestamp}] [{$level}] {$message}";
$this->logs[] = $log_line;
if (!$this->is_immediate && $this->id !== null) {
DB::table('_task_queue')
->where('id', $this->id)
->update([
'logs' => implode("\n", $this->logs),
'updated_at' => now(),
]);
}
}
/**
* Log an info message
*
* @param string $message
*/
public function info(string $message): void
{
$this->log('info', $message);
}
/**
* Log an error message
*
* @param string $message
*/
public function error(string $message): void
{
$this->log('error', $message);
}
/**
* Log a debug message
*
* @param string $message
*/
public function debug(string $message): void
{
$this->log('debug', $message);
}
/**
* Update task progress percentage
*
* @param int $percent Progress percentage (0-100)
* @param string|null $message Optional progress message
*/
public function update_progress(int $percent, ?string $message = null): void
{
$percent = max(0, min(100, $percent));
if ($message) {
$this->info("Progress: {$percent}% - {$message}");
} else {
$this->info("Progress: {$percent}%");
}
}
/**
* Set task result data
*
* @param mixed $result Result data (will be JSON-encoded)
*/
public function set_result($result): void
{
if (!$this->is_immediate && $this->id !== null) {
DB::table('_task_queue')
->where('id', $this->id)
->update([
'result' => json_encode($result),
'updated_at' => now(),
]);
}
}
/**
* Send heartbeat to indicate task is still running
*/
public function heartbeat(): void
{
if (!$this->is_immediate && $this->id !== null) {
DB::table('_task_queue')
->where('id', $this->id)
->update([
'last_heartbeat_at' => now(),
'updated_at' => now(),
]);
}
}
/**
* Get or create temporary directory for this task
*
* @return string Absolute path to temp directory
*/
public function get_temp_dir(): string
{
if ($this->temp_dir !== null) {
return $this->temp_dir;
}
$base_temp_dir = storage_path('rsx-tmp/tasks');
if (!is_dir($base_temp_dir)) {
mkdir($base_temp_dir, 0755, true);
}
if ($this->is_immediate) {
$dir_name = 'immediate_' . uniqid();
} else {
$dir_name = 'task_' . $this->id;
}
$this->temp_dir = $base_temp_dir . '/' . $dir_name;
if (!is_dir($this->temp_dir)) {
mkdir($this->temp_dir, 0755, true);
}
return $this->temp_dir;
}
/**
* Clean up temporary directory
*/
public function cleanup_temp_dir(): void
{
if ($this->temp_dir === null || !is_dir($this->temp_dir)) {
return;
}
$this->delete_directory_recursive($this->temp_dir);
$this->temp_dir = null;
}
/**
* Recursively delete directory and contents
*
* @param string $dir Directory path
*/
private function delete_directory_recursive(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
if (is_dir($path)) {
$this->delete_directory_recursive($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/**
* Get task ID
*
* @return int|null
*/
public function get_id(): ?int
{
return $this->id;
}
/**
* Get task class name
*
* @return string
*/
public function get_class(): string
{
return $this->class;
}
/**
* Get task method name
*
* @return string
*/
public function get_method(): string
{
return $this->method;
}
/**
* Get task parameters
*
* @return array
*/
public function get_params(): array
{
return $this->params;
}
/**
* Get task queue name
*
* @return string
*/
public function get_queue(): string
{
return $this->queue;
}
/**
* Get task status
*
* @return Task_Status
*/
public function get_status(): Task_Status
{
return $this->status;
}
/**
* Get all task logs
*
* @return array
*/
public function get_logs(): array
{
return $this->logs;
}
/**
* Check if task is immediate (not database-backed)
*
* @return bool
*/
public function is_immediate(): bool
{
return $this->is_immediate;
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\RSpade\Core\Task;
use Illuminate\Support\Facades\DB;
/**
* Task_Lock
*
* Manages MySQL advisory locks for atomic task queue operations.
* Uses GET_LOCK() and RELEASE_LOCK() for distributed locking.
*/
#[Instantiatable]
class Task_Lock
{
private string $lock_name;
private int $timeout;
private bool $is_locked = false;
/**
* Create a new task lock instance
*
* @param string $lock_name Unique lock identifier
* @param int $timeout Lock timeout in seconds (default: 10)
*/
public function __construct(string $lock_name, int $timeout = 10)
{
$this->lock_name = $lock_name;
$this->timeout = $timeout;
}
/**
* Acquire the lock
*
* @return bool True if lock acquired, false if already held by another process
*/
public function acquire(): bool
{
if ($this->is_locked) {
return true;
}
$result = DB::selectOne("SELECT GET_LOCK(?, ?) as acquired", [
$this->lock_name,
$this->timeout,
]);
$this->is_locked = (bool) $result->acquired;
return $this->is_locked;
}
/**
* Release the lock
*
* @return bool True if lock was released, false if lock was not held
*/
public function release(): bool
{
if (!$this->is_locked) {
return false;
}
$result = DB::selectOne("SELECT RELEASE_LOCK(?) as released", [
$this->lock_name,
]);
$was_released = (bool) $result->released;
if ($was_released) {
$this->is_locked = false;
}
return $was_released;
}
/**
* Check if the lock is currently held by this instance
*
* @return bool
*/
public function is_locked(): bool
{
return $this->is_locked;
}
/**
* Check if the lock is currently held by ANY process
*
* @return bool
*/
public function is_in_use(): bool
{
$result = DB::selectOne("SELECT IS_USED_LOCK(?) as in_use", [
$this->lock_name,
]);
return $result->in_use !== null;
}
/**
* Automatically release lock on destruction
*/
public function __destruct()
{
if ($this->is_locked) {
$this->release();
}
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\RSpade\Core\Task;
/**
* Task_Status
*
* Value object representing task execution status.
* Provides type-safe status constants and helper methods.
*/
#[Instantiatable]
class Task_Status
{
// Status constants
public const PENDING = 'pending';
public const RUNNING = 'running';
public const COMPLETED = 'completed';
public const FAILED = 'failed';
public const STUCK = 'stuck';
private string $status;
public function __construct(string $status)
{
if (!in_array($status, [self::PENDING, self::RUNNING, self::COMPLETED, self::FAILED, self::STUCK])) {
throw new \InvalidArgumentException("Invalid task status: {$status}");
}
$this->status = $status;
}
public function value(): string
{
return $this->status;
}
public function is_pending(): bool
{
return $this->status === self::PENDING;
}
public function is_running(): bool
{
return $this->status === self::RUNNING;
}
public function is_completed(): bool
{
return $this->status === self::COMPLETED;
}
public function is_failed(): bool
{
return $this->status === self::FAILED;
}
public function is_stuck(): bool
{
return $this->status === self::STUCK;
}
public function is_terminal(): bool
{
return $this->is_completed() || $this->is_failed();
}
public function __toString(): string
{
return $this->status;
}
}

10
app/RSpade/Core/constants.php Executable file
View File

@@ -0,0 +1,10 @@
<?php
/**
* RSpade Framework Constants
*
* Global constants used throughout the framework
*/
// SPA view name
define('SPA', 'Spa_App');

View File

@@ -318,7 +318,7 @@ class Ide_Helper_Controller
// Priority 5: jqhtml JavaScript classes (when type is 'jqhtml_class')
if ($type === 'jqhtml_class') {
try {
// Look for JS class extending Jqhtml_Component
// Look for JS class extending Component
$files = Manifest::get_all();
// Search for .js files containing the class
@@ -329,9 +329,9 @@ class Ide_Helper_Controller
if (file_exists($absolute_path)) {
$content = file_get_contents($absolute_path);
// Check if this file contains a class with the right name extending Jqhtml_Component
// Look for patterns like: class ComponentName extends Jqhtml_Component
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Jqhtml_Component/', $content)) {
// Check if this file contains a class with the right name extending Component
// Look for patterns like: class ComponentName extends Component
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Component/', $content)) {
$lines = explode("\n", $content);
$line_number = 1;
@@ -364,7 +364,7 @@ class Ide_Helper_Controller
// Special case: if method_name is 'data', look for 'on_load' instead
$search_method = ($method_name === 'data') ? 'on_load' : $method_name;
// Look for JS class extending Jqhtml_Component and find the method
// Look for JS class extending Component and find the method
$files = Manifest::get_all();
// Search for .js files containing the class
@@ -375,8 +375,8 @@ class Ide_Helper_Controller
if (file_exists($absolute_path)) {
$content = file_get_contents($absolute_path);
// Check if this file contains a class with the right name extending Jqhtml_Component
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Jqhtml_Component/', $content)) {
// Check if this file contains a class with the right name extending Component
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Component/', $content)) {
$lines = explode("\n", $content);
$line_number = 1;
$in_class = false;

View File

@@ -369,7 +369,7 @@ function handle_definition_service($data) {
break;
case 'jqhtml_class':
// Search for JavaScript class extending Jqhtml_Component
// Search for JavaScript class extending Component
foreach ($manifest['jqhtml']['components'] ?? [] as $component) {
if ($component['name'] === $identifier && isset($component['js_file'])) {
$result = [
@@ -840,7 +840,7 @@ function try_resolve_js_class($identifier, $method_name, $files) {
if (file_exists($absolute_path)) {
$content = file_get_contents($absolute_path);
// Check for JavaScript class (not specifically Jqhtml_Component)
// Check for JavaScript class (not specifically Component)
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+([A-Za-z_][A-Za-z0-9_]*)/', $content, $matches)) {
$lines = explode("\n", $content);
$line_number = 1;
@@ -872,7 +872,7 @@ function try_resolve_js_class($identifier, $method_name, $files) {
*
* Note: This resolver does NOT check inheritance chains. It's a simple file lookup.
* VS Code extension should use js_is_subclass_of() RPC to determine if a class
* is actually a Jqhtml_Component before choosing this resolver type.
* is actually a Component before choosing this resolver type.
*/
function try_resolve_jqhtml_class($identifier, $method_name, $files) {
// Load manifest to get js_classes index
@@ -1055,7 +1055,7 @@ function try_resolve_jqhtml_method($identifier, $method_name, $files) {
if (file_exists($absolute_path)) {
$content = file_get_contents($absolute_path);
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Jqhtml_Component/', $content)) {
if (preg_match('/class\s+' . preg_quote($identifier, '/') . '\s+extends\s+[A-Za-z_]*Component/', $content)) {
$lines = explode("\n", $content);
$line_number = 1;
$in_class = false;
@@ -1114,11 +1114,11 @@ function try_resolve_jqhtml_method($identifier, $method_name, $files) {
*
* 2. js_class - JavaScript class files (*.js, not .jqhtml)
* - Searches for JS classes with extends clause
* - Does NOT require extending Jqhtml_Component
* - Does NOT require extending Component
* - Returns: file path, line number, extends
*
* 3. jqhtml_class - jqhtml component JavaScript files (*.js)
* - Searches for classes extending Jqhtml_Component
* - Searches for classes extending Component
* - Returns: file path, line number
*
* 4. view - Blade view templates (*.blade.php)
@@ -1135,7 +1135,7 @@ function try_resolve_jqhtml_method($identifier, $method_name, $files) {
*
* 7. jqhtml_class_method - Methods in jqhtml component classes
* - Requires both identifier and method parameters
* - Searches within Jqhtml_Component subclasses
* - Searches within Component subclasses
* - Returns: file path, method line number
*
* CSV TYPE LISTS:

View File

@@ -1,13 +1,13 @@
/**
* Jqhtml_Component - Base class for JQHTML components in RSX framework
* Component - Base class for JQHTML components in RSX framework
*
* This class wraps the jqhtml.Component from the npm package and provides
* the standard interface for RSX components following the Upper_Case naming convention.
* the standard interface for RSX components.
*
* _Base_Jqhtml_Component is imported from npm via Jqhtml_Bundle.
*
* @Instantiatable
*/
class Jqhtml_Component extends _Base_Jqhtml_Component {}
class Component extends _Base_Jqhtml_Component {}
// RSX manifest automatically makes classes global - no manual assignment needed

View File

@@ -25,7 +25,7 @@ class Jqhtml
{
if ($slot_content !== '') {
return sprintf(
'<div class="Jqhtml_Component_Init" data-component-init-name="%s" data-component-args="%s">%s</div>',
'<div class="Component_Init" data-component-init-name="%s" data-component-args="%s">%s</div>',
htmlspecialchars($component_name),
htmlspecialchars(json_encode($args), ENT_QUOTES, 'UTF-8'),
$slot_content
@@ -33,7 +33,7 @@ class Jqhtml
}
return sprintf(
'<div class="Jqhtml_Component_Init" data-component-init-name="%s" data-component-args="%s"></div>',
'<div class="Component_Init" data-component-init-name="%s" data-component-args="%s"></div>',
htmlspecialchars($component_name),
htmlspecialchars(json_encode($args), ENT_QUOTES, 'UTF-8')
);

View File

@@ -149,13 +149,13 @@ class JqhtmlBladeCompiler
}
// Build HTML attributes string
// Handle class attribute specially to merge with Jqhtml_Component_Init
$class_value = 'Jqhtml_Component_Init';
// Handle class attribute specially to merge with Component_Init
$class_value = 'Component_Init';
if (isset($html_attrs['class'])) {
if ($html_attrs['class']['type'] === 'expression') {
$class_value = "Jqhtml_Component_Init ' . {$html_attrs['class']['value']} . '";
$class_value = "Component_Init ' . {$html_attrs['class']['value']} . '";
} else {
$class_value = 'Jqhtml_Component_Init ' . $html_attrs['class']['value'];
$class_value = 'Component_Init ' . $html_attrs['class']['value'];
}
}
@@ -229,13 +229,13 @@ class JqhtmlBladeCompiler
}
// Build HTML attributes string
// Handle class attribute specially to merge with Jqhtml_Component_Init
$class_value = 'Jqhtml_Component_Init';
// Handle class attribute specially to merge with Component_Init
$class_value = 'Component_Init';
if (isset($html_attrs['class'])) {
if ($html_attrs['class']['type'] === 'expression') {
$class_value = "Jqhtml_Component_Init ' . {$html_attrs['class']['value']} . '";
$class_value = "Component_Init ' . {$html_attrs['class']['value']} . '";
} else {
$class_value = 'Jqhtml_Component_Init ' . $html_attrs['class']['value'];
$class_value = 'Component_Init ' . $html_attrs['class']['value'];
}
}

View File

@@ -2,7 +2,7 @@
* JQHTML Integration - Automatic component registration and binding
*
* This module automatically:
* 1. Registers component classes that extend Jqhtml_Component
* 1. Registers component classes that extend Component
* 2. Binds templates to component classes when names match
* 3. Enables $(selector).component("Component_Name") syntax
*/
@@ -13,13 +13,54 @@ class Jqhtml_Integration {
* of framework init.
*/
static _on_framework_modules_define() {
let jqhtml_components = Manifest.get_extending('Jqhtml_Component');
let jqhtml_components = Manifest.get_extending('Component');
console_debug('JQHTML_INIT', 'Registering ' + jqhtml_components.length + ' Jqhtml Components');
console_debug('JQHTML_INIT', 'Registering ' + jqhtml_components.length + ' Components');
for (let component of jqhtml_components) {
jqhtml.register_component(component.class_name, component.class_object);
}
// Assign unique cache IDs to all static methods for component caching
// This enables JQHTML to generate deterministic cache keys when functions
// are passed as component arguments (e.g., DataGrid data_source functions)
const all_classes = Manifest.get_all_classes();
let methods_tagged = 0;
for (const class_info of all_classes) {
const class_object = class_info.class_object;
const class_name = class_info.class_name;
// Get all property names from the class object (static methods/properties)
const property_names = Object.getOwnPropertyNames(class_object);
for (const property_name of property_names) {
// Skip built-in properties and non-functions
if (property_name === 'length' ||
property_name === 'name' ||
property_name === 'prototype') {
continue;
}
const property_value = class_object[property_name];
// Only tag functions (static methods)
if (typeof property_value === 'function') {
// Assign unique cache ID: "ClassName.methodName"
property_value._jqhtml_cache_id = `${class_name}.${property_name}`;
methods_tagged++;
}
}
}
console_debug('JQHTML_INIT', `Tagged ${methods_tagged} static methods with _jqhtml_cache_id`);
// Set the cache key for jqhtml to the application key to enable component caching in local storage.
// Modifying the application or the user logging out modifies the scope_key and will invalidate jqhtml cache.
jqhtml.set_cache_key(Rsx.scope_key());
// set this to true if we desire jqhtml verbose output (probably not necessary for end developers)
window.jqhtml.debug.verbose = false;
}
/**
@@ -31,7 +72,7 @@ class Jqhtml_Integration {
static _on_framework_modules_init($scope) {
const is_top_level = !$scope;
const promises = [];
const components_needing_init = ($scope || $('body')).find('.Jqhtml_Component_Init');
const components_needing_init = ($scope || $('body')).find('.Component_Init');
if (components_needing_init.length > 0) {
console_debug('JQHTML_INIT', `Initializing ${components_needing_init.length} DOM components`);
}
@@ -45,10 +86,10 @@ class Jqhtml_Integration {
return;
}
// Check if any parent has Jqhtml_Component_Init class - skip nested components
// Check if any parent has Component_Init class - skip nested components
let parent = $element[0].parentElement;
while (parent) {
if (parent.classList.contains('Jqhtml_Component_Init')) {
if (parent.classList.contains('Component_Init')) {
return; // Skip this element, it's nested
}
parent = parent.parentElement;
@@ -97,14 +138,14 @@ class Jqhtml_Integration {
$element.empty();
// Remove the init class before instantiation to prevent re-initialization
$element.removeClass('Jqhtml_Component_Init');
$element.removeClass('Component_Init');
// Create promise for this component's initialization
const component_promise = new Promise((resolve) => {
// Use jQuery component plugin to create the component
// Plugin handles element internally, just pass args
// Get the updated $element from
let component = $element.component(component_name, component_args_filtered);
let component = $element.component(component_name, component_args_filtered).component();
component.on('render', function () {
// Recursively collect promises from nested components

View File

@@ -1,3 +1,3 @@
.Jqhtml_Component_Init {
.Component_Init {
display:none;
}

View File

@@ -29,10 +29,10 @@ class Jqhtml_ManifestSupport extends ManifestSupport_Abstract
$components = [];
// Build map of component_name => js_file for classes extending Jqhtml_Component
// Build map of component_name => js_file for classes extending Component
$js_classes = [];
try {
$extending_components = Manifest::js_get_extending('Jqhtml_Component');
$extending_components = Manifest::js_get_extending('Component');
foreach ($extending_components as $component_info) {
if (isset($component_info['class']) && isset($component_info['file'])) {
$js_classes[$component_info['class']] = $component_info['file'];

View File

@@ -29,7 +29,7 @@ class Scss_ManifestModule extends ManifestModule_Abstract
* Process a SCSS/CSS file and extract metadata
*
* For SCSS files, also detects if the file has a single top-level class selector
* that matches a Blade view ID, JavaScript class extending Jqhtml_Component,
* that matches a Blade view ID, JavaScript class extending Component,
* or jqhtml template ID in the manifest.
*/
public function process(string $file_path, array $metadata): array
@@ -213,7 +213,7 @@ class Scss_ManifestModule extends ManifestModule_Abstract
*
* The SCSS file gets an 'id' if:
* 1. All rules are contained within a single top-level class selector
* 2. The class name matches a Blade view ID, JS class extending Jqhtml_Component, or jqhtml template
* 2. The class name matches a Blade view ID, JS class extending Component, or jqhtml template
* 3. No other SCSS file already has this ID
*/
protected function detect_scss_id(string $clean_content, array &$metadata): void
@@ -273,10 +273,10 @@ class Scss_ManifestModule extends ManifestModule_Abstract
$found_match = true;
}
// Check for JavaScript class extending Jqhtml_Component
// Check for JavaScript class extending Component
if (isset($file_data['extension']) && $file_data['extension'] === 'js' &&
isset($file_data['class']) && $file_data['class'] === $class_name &&
isset($file_data['extends']) && $file_data['extends'] === 'Jqhtml_Component') {
isset($file_data['extends']) && $file_data['extends'] === 'Component') {
$found_match = true;
}
}

360
app/RSpade/Lib/Flash/CLAUDE.md Executable file
View File

@@ -0,0 +1,360 @@
# Flash Alert System
The Flash system provides server-to-client messaging that persists across redirects and Ajax calls, with client-side queue persistence for seamless navigation.
## Architecture Overview
**Data Flow:**
1. PHP code creates flash message → stored in database with type_id and session_id
2. Message expires after 1 minute if not rendered
3. On page render OR Ajax response → messages retrieved and deleted from database
4. Client receives messages via `window.rsxapp.flash_alerts` or `response.flash_alerts`
5. `Server_Side_Flash.js` processes messages → calls `Flash_Alert.js` display methods
6. Client queue persisted to sessionStorage (per-tab) → survives page navigations
7. On page load → restore queue from sessionStorage (messages < 20 seconds old)
## PHP Side - Creating Flash Messages
### Basic Usage
```php
use App\RSpade\Lib\Flash\Flash_Alert;
// In controllers
Flash_Alert::success('Account created successfully!');
Flash_Alert::error('Invalid email address');
Flash_Alert::info('Your session will expire in 5 minutes');
Flash_Alert::warning('This action cannot be undone');
```
### How It Works
**Storage:**
- Messages stored in `flash_alerts` table with `type_id` (enum), `message`, and `session_id`
- Uses integer enum: 1=success, 2=error, 3=info, 4=warning (see Flash_Alert_Model)
- Linked to session via `Session::get_session_id()`
- No session = messages silently not created
**Retrieval:**
- `Flash_Alert::get_pending_messages()` retrieves all pending messages for current session
- Automatically deletes expired messages (> 1 minute old) before retrieval
- Automatically deletes retrieved messages (one-time display)
- Returns array: `[['type' => 'success', 'message' => '...'], ...]`
**Automatic Integration:**
- Page rendering: Added to `window.rsxapp.flash_alerts` in bundle render (Rsx_Bundle_Abstract.php:290)
- Ajax responses: Added to `response.flash_alerts` in both success and error responses (Ajax.php:347, 447)
## JavaScript Side - Displaying Flash Messages
### Client Components
**Flash_Alert.js** (`/system/app/RSpade/Lib/Flash/Flash_Alert.js`):
- Client-side display component with queue system, auto-dismiss, animations
- Methods: `Flash_Alert.success()`, `.error()`, `.info()`, `.warning()`
- Features: 2.5s queue spacing, auto-dismiss (4s success, 6s others), click-to-dismiss
- Queue persistence: Saves state to sessionStorage on queue changes, restores on page load
- Stale message filtering: Only restores messages < 20 seconds old
- Styling: Bootstrap alert classes with icons
**Server_Side_Flash.js** (`/system/app/RSpade/Lib/Flash/Server_Side_Flash.js`):
- Bridge between server data and Flash_Alert display
- Processes `flash_alerts` arrays from server
- Restores persisted queue state from sessionStorage on framework init
- Called automatically on page load (framework init hook)
- Called automatically on Ajax responses (Ajax.js:190)
### How Client Processing Works
**Page Load:**
```javascript
// Automatic via _on_framework_core_init() hook
// 1. Restore persisted queue from sessionStorage (only fresh messages)
Flash_Alert._restore_queue_state();
// 2. Process new server messages
if (window.rsxapp && window.rsxapp.flash_alerts) {
Server_Side_Flash.process(window.rsxapp.flash_alerts);
}
```
**Ajax Responses:**
```javascript
// Automatic in Ajax.js success handler
if (response.flash_alerts && Array.isArray(response.flash_alerts)) {
Server_Side_Flash.process(response.flash_alerts);
}
```
**Processing Logic:**
```javascript
Server_Side_Flash.process(flash_alerts) {
flash_alerts.forEach(alert => {
const method = alert.type; // 'success', 'error', 'info', 'warning'
Flash_Alert[method](alert.message); // Calls Flash_Alert.success(), etc.
});
}
```
## Database Schema
**Table:** `flash_alerts`
```sql
CREATE TABLE flash_alerts (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
session_id BIGINT NOT NULL,
type_id BIGINT NOT NULL, -- 1=success, 2=error, 3=info, 4=warning
message LONGTEXT NOT NULL,
created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT,
updated_by BIGINT,
updated_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
INDEX idx_session_id (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
**Model:** `Flash_Alert_Model` with type_id enum definition
## Common Use Cases
**Post-Redirect Flash:**
```php
public static function create_account(Request $request, array $params = []) {
$user = new User_Model();
$user->email = $request->input('email');
$user->save();
Flash_Alert::success('Account created successfully!');
return redirect(Rsx::Route('Dashboard_Controller'));
}
```
**Ajax Error Handling:**
```php
#[Ajax_Endpoint]
public static function save_settings(Request $request, array $params = []) {
if (!$request->input('email')) {
Flash_Alert::error('Email is required');
return response_form_error('Validation failed', ['email' => 'Required']);
}
// Save settings...
Flash_Alert::success('Settings saved!');
return ['saved' => true];
}
```
**Multi-Step Workflows:**
```php
// Step 1
Flash_Alert::info('Please verify your email before continuing');
return redirect(Rsx::Route('Verify_Email_Controller'));
// Step 2 (after email verified)
Flash_Alert::success('Email verified! Your account is now active');
return redirect(Rsx::Route('Dashboard_Controller'));
```
## Migration from Old System
**Old (removed):**
```php
Rsx::flash_success('Message');
Rsx::flash_error('Message');
Rsx::render_flash_alerts(); // In blade views
```
**New:**
```php
use App\RSpade\Lib\Flash\Flash_Alert;
Flash_Alert::success('Message');
Flash_Alert::error('Message');
// No render call needed - automatic via window.rsxapp
```
## Key Design Decisions
**Why 1-minute expiration?**
- Prevents stale messages from appearing hours/days later
- Covers normal redirect timing (< 1 second)
- Covers slow network conditions
- Aggressive cleanup keeps database small
**Why delete on retrieval?**
- One-time display semantics (flash = temporary)
- Prevents duplicate display on page refresh
- Simpler than tracking "displayed" status
**Why session-based?**
- Links messages to specific user session
- Automatic cleanup when session expires
- No messages shown without session (no logged-out flash)
**Why both page render AND Ajax integration?**
- Page render: Handles redirects (Flash_Alert::success() → redirect → display)
- Ajax: Handles same-page updates (Flash_Alert::success() → Ajax response → display)
- Unified API works everywhere
**Why sessionStorage persistence?**
- Handles Ajax + immediate redirect scenario (message queued, then page navigates)
- Per-tab isolation (messages don't leak across browser tabs)
- Messages survive page navigation (up to 20 seconds)
- Automatic cleanup (stale messages filtered out, removed on dismiss)
- No server-side complexity (client handles queue restoration)
## Queue Persistence System
### Architecture Overview
**Two-queue design:**
- **Working queue** (`_queue`): Controls display timing and spacing (2.5s between alerts)
- **Persistence queue** (`_persistence_queue`): Saved to sessionStorage, survives navigation
**Why two queues?**
- Working queue: Messages removed when displayed → prevents duplicate displays
- Persistence queue: Messages removed when fadeout starts → maintains state across navigation
- Separation ensures proper timing while enabling seamless cross-page experience
### Storage Mechanism
**Storage type:** sessionStorage (per-tab, survives navigation, cleared on tab close)
**Storage key:** `rsx_flash_queue`
**Stored data structure:**
```javascript
{
last_updated: 1234567890, // Timestamp of last save
messages: [
{
message: "Success!",
level: "success",
timeout: null,
position: "top",
queued_at: 1234567890,
fade_in_complete: false, // Set true after fade-in animation
fadeout_start_time: null // Timestamp when fadeout should begin
}
]
}
```
### Message Lifecycle & State Tracking
**Lifecycle stages:**
1. **Queued**: Added to both queues, saved to sessionStorage
2. **Displaying** (fade-in): 400ms fade-in animation
3. **Fade-in complete**: `fade_in_complete = true`, `fadeout_start_time` calculated and saved
4. **Visible**: Display duration (4s success, 6s others)
5. **Fadeout**: 1s opacity fade + 250ms slide up, removed from persistence queue
6. **Removed**: Element removed from DOM
**State tracking:**
- `fade_in_complete`: Marks when fade-in animation completes
- `fadeout_start_time`: Absolute timestamp when fadeout should begin
- `last_updated`: Queue-level timestamp for staleness check
**Why track timing state?**
- Enables SPA-like experience: alerts maintain consistent timing across page navigations
- Prevents timing restarts: navigating mid-alert doesn't reset the display duration
- Allows immediate display: alerts that completed fade-in on previous page show instantly on next page
### When State Changes
**State saved to sessionStorage:**
- New message queued (`Flash_Alert._show()`)
- Fade-in completes (`_mark_fade_in_complete()`)
- Fadeout scheduled (`_set_fadeout_start_time()`)
- Message removed from persistence queue (`_remove_from_persistence_queue()`)
**State restored from sessionStorage:**
- On page load via `Server_Side_Flash._on_framework_core_init()` hook
- Before processing new server messages
- Applies staleness filter and fadeout time filter
**State cleared:**
- Fadeout begins for individual messages (removed from persistence queue)
- Entire queue becomes stale (last_updated > 20 seconds)
- All messages dismissed/removed (storage key deleted)
### Staleness & Filtering
**20-second rule:**
- If `last_updated` timestamp is > 20 seconds old, entire queue is discarded
- Prevents ancient messages from appearing after long delays
- Based on queue-level timestamp, not individual message age
**Fadeout time filter:**
- On restore, messages past their `fadeout_start_time` are discarded
- Prevents "zombie" messages that should have already faded out
- Only applies to messages with scheduled fadeouts (fade-in complete)
### Navigation Behavior
**On page navigation (beforeunload):**
- Alerts still fading in: Hidden immediately (will restore and continue fade-in on next page)
- Alerts fully visible: Remain visible during navigation (with scheduled fadeout)
- Persistence queue: Unchanged (all state preserved to sessionStorage)
**On page load (restoration):**
- Messages with `fade_in_complete = true`:
- Displayed immediately (no fade-in animation, no queue delay)
- Honor original `fadeout_start_time` (not restarted)
- Displayed outside normal queue (doesn't block queue processing)
- Messages with `fade_in_complete = false`:
- Added to working queue for normal processing
- Display with 2.5s spacing and full fade-in animation
- Calculate new `fadeout_start_time` after fade-in completes
### Common Scenarios
**Scenario 1: Ajax + Redirect (primary use case)**
1. Ajax response includes flash message
2. JavaScript queues message and saves to sessionStorage
3. Page redirects immediately (before message displays)
4. New page loads, restores queue from sessionStorage
5. Message displays normally with 2.5s queue spacing
6. Message removed from storage when fadeout begins
**Scenario 2: Mid-animation navigation**
1. Alert is fading in (fade_in_complete = false)
2. User navigates away (alert hidden by beforeunload)
3. New page loads, message restored to working queue
4. Alert displays with normal queue timing and fade-in animation
5. After fade-in completes, fade_in_complete = true saved
**Scenario 3: Visible alert navigation (seamless SPA-like)**
1. Alert fully visible (fade_in_complete = true, fadeout_start_time set)
2. User navigates away (alert remains visible during navigation)
3. New page loads, message has fade_in_complete = true
4. Alert displayed immediately (no animation, no delay)
5. Honors original fadeout_start_time (e.g., if 2s remaining, fades out in 2s)
6. Creates seamless experience: alert appears to "survive" navigation
**Scenario 4: Multiple messages + navigation**
1. Queue has 3 messages, first one displaying (fade_in_complete = true)
2. User navigates away
3. New page loads:
- First message (fade_in_complete = true): Shows immediately
- Second message (fade_in_complete = false): Added to queue, displays in 2.5s
- Third message (fade_in_complete = false): Added to queue, displays in 5s
4. Queue processing continues normally for remaining messages
## File Locations
**PHP:**
- `/system/app/RSpade/Lib/Flash/Flash_Alert.php` - Main Flash class
- `/system/app/RSpade/Lib/Flash/Flash_Alert_Model.php` - Database model with type_id enum
- Integration in `/system/app/RSpade/Core/Bundle/Rsx_Bundle_Abstract.php:290`
- Integration in `/system/app/RSpade/Core/Ajax/Ajax.php:347, 447`
**JavaScript:**
- `/system/app/RSpade/Lib/Flash/Flash_Alert.js` - Display component with persistence
- `/system/app/RSpade/Lib/Flash/Flash_Alert.scss` - Styles
- `/system/app/RSpade/Lib/Flash/Server_Side_Flash.js` - Server bridge
- Integration in `/system/app/RSpade/Core/Js/Ajax.js:190`
**Database:**
- Migration: `/system/database/migrations/2025_11_13_193740_modify_flash_alerts_table_for_type_based_system.php`

View File

@@ -0,0 +1,589 @@
/**
* Flash_Alert - Temporary alert notification system with queue persistence
*
* Displays dismissable alert messages that auto-fade after a timeout.
* Messages are queued to prevent overwhelming the user with simultaneous alerts.
* Queue state persists across page navigations using sessionStorage (per-tab).
*
* Usage:
* Flash_Alert.success('Changes saved!')
* Flash_Alert.error('Something went wrong')
* Flash_Alert.info('FYI: New feature available')
* Flash_Alert.warning('Are you sure about that?')
*
* Features:
* - Queue system prevents alert spam (2.5s minimum between alerts)
* - Auto-dismiss after timeout (success: 4s, others: 6s)
* - Click to dismiss immediately
* - Smooth fade in/out animations
* - Bootstrap alert styling with icons
* - Persistent queue across page navigations (sessionStorage, tab-specific)
* - Stale message filtering (entire queue discarded if > 20 seconds old)
* - SPA-like experience: messages maintain timing state across navigation
*
* Queue Architecture:
* - Two-queue system: working queue (_queue) and persistence queue (_persistence_queue)
* - Working queue: Messages removed when displayed (controls display timing/spacing)
* - Persistence queue: Messages removed when fadeout starts (saved to sessionStorage)
* - Prevents duplicate displays while maintaining cross-navigation state
*
* Persistence System:
* - Queue state saved to sessionStorage when messages queued or state changes
* - State restored on page load via Server_Side_Flash._on_framework_core_init()
* - Entire queue discarded if last_updated timestamp > 20 seconds old
* - Individual messages filtered: discard if past their fadeout_start_time
* - Per-tab isolation (sessionStorage doesn't share across browser tabs)
* - Handles Ajax response + redirect scenario (message survives navigation)
*
* Message Lifecycle & Timing:
* - Queued → Display (fade-in 400ms) → Visible (4s success, 6s others) → Fadeout (1s) → Removed
* - fade_in_complete flag: Set after fade-in animation completes
* - fadeout_start_time: Calculated timestamp when fadeout should begin
* - Both saved to sessionStorage to maintain timing consistency across navigation
*
* Navigation Behavior:
* - beforeunload event: Hides alerts still fading in, keeps fully visible alerts (with scheduled fadeout)
* - On restore: Messages with fade_in_complete=true show immediately (no fade-in, no queue delay)
* - On restore: Messages with fade_in_complete=false added to queue (normal 2.5s spacing)
* - Restored messages honor original fadeout_start_time (not restarted)
* - Creates seamless SPA-like experience across full page navigations
*
* Common Scenarios:
* 1. Ajax + Redirect: Flash message queued during Ajax, page redirects immediately
* → Message saved to sessionStorage → Restored and displayed on new page
* 2. Mid-animation navigation: Alert fading in when user navigates away
* → Alert hidden during navigation → Restored on new page, continues fade-in
* 3. Visible alert navigation: Alert fully visible when user navigates
* → Alert stays visible → Appears immediately on new page, honors original fadeout timing
*/
class Flash_Alert {
// Queue and state tracking
static _queue = []; // Working queue - messages removed when displayed
static _persistence_queue = []; // Persistence queue - messages removed when fadeout starts
static _is_in_progress = false;
static _last_alert_time = 0;
static _container = null;
/**
* Initialize the flash alert container
* @private
*/
static _init() {
if (this._container) return;
// Create floating alert container
this._container = $('<div id="floating-alert-container" class="floating-alert-container"></div>');
$('body').append(this._container);
// Register page navigation handler (only once)
window.addEventListener('beforeunload', () => {
console.log('[Flash_Alert] Page navigation detected - cleaning up');
this._cleanup_for_navigation();
});
}
/**
* Cleanup alerts on page navigation
* Hides alerts still fading in, leaves fully faded-in alerts visible with scheduled fadeout
* @private
*/
static _cleanup_for_navigation() {
console.log('[Flash_Alert] Cleaning up for navigation:', {
working_queue_before: this._queue.length,
persistence_queue: this._persistence_queue.length,
});
if (this._container) {
const now = Date.now();
// Process each visible alert
this._container.find('.alert-wrapper').each((index, wrapper) => {
const $wrapper = $(wrapper);
const message_text = $wrapper.find('.alert').text().trim().replace(/×$/, '').trim();
// Find this message in persistence queue to check fade_in_complete
const msg = this._persistence_queue.find(m => m.message === message_text);
if (msg && msg.fade_in_complete) { // @JS-DEFENSIVE-01-EXCEPTION - Array.find() returns undefined when no match is found
// Alert has fully faded in - leave it visible
console.log('[Flash_Alert] Leaving fully faded-in alert visible:', message_text);
// Schedule fadeout based on fadeout_start_time
if (msg.fadeout_start_time) {
const time_until_fadeout = Math.max(0, msg.fadeout_start_time - now);
setTimeout(() => {
console.log('[Flash_Alert] Navigation fadeout starting for:', message_text);
// Fade to transparent, then slide up
$wrapper.animate({ opacity: 0 }, 1000, () => {
$wrapper.slideUp(250, () => {
$wrapper.remove();
});
});
}, time_until_fadeout);
console.log('[Flash_Alert] Scheduled fadeout for visible alert:', {
message: message_text,
time_until_fadeout,
fadeout_start_time: msg.fadeout_start_time
});
}
} else {
// Alert still fading in - remove it
console.log('[Flash_Alert] Removing alert still fading in:', message_text);
$wrapper.remove();
}
});
}
// Clear working queue (stop processing new alerts)
this._queue = [];
// Stop processing
this._is_in_progress = false;
// DO NOT touch _persistence_queue or sessionStorage
// They will be restored on the next page load
console.log('[Flash_Alert] Cleanup complete - persistence queue preserved');
}
/**
* Save persistence queue state to sessionStorage
* @private
*/
static _save_queue_state() {
const state = {
last_updated: Date.now(),
messages: this._persistence_queue.map((item) => ({
message: item.message,
level: item.level,
timeout: item.timeout,
position: item.position,
queued_at: item.queued_at,
fade_in_complete: item.fade_in_complete || false,
fadeout_start_time: item.fadeout_start_time || null,
})),
};
console.log('[Flash_Alert] Saving persistence queue to sessionStorage:', {
message_count: state.messages.length,
last_updated: state.last_updated,
messages: state.messages,
});
Rsx_Storage.session_set('rsx_flash_queue', state);
}
/**
* Load queue state from sessionStorage
* Discards entire queue if last_updated timestamp is > 20 seconds old
* @private
*/
static _restore_queue_state() {
const state = Rsx_Storage.session_get('rsx_flash_queue');
console.log('[Flash_Alert] Attempting to restore queue state from sessionStorage:', {
has_stored_data: !!state,
stored_data: state,
});
if (!state) {
console.log('[Flash_Alert] No stored queue data found');
return;
}
try {
const now = Date.now();
const MAX_AGE = 20000; // 20 seconds in milliseconds
console.log('[Flash_Alert] Parsed stored state:', {
message_count: state.messages.length,
last_updated: state.last_updated,
messages: state.messages,
current_time: now,
});
// Check if the stored queue is > 20 seconds old
// If so, throw out the entire queue
const queue_age = now - state.last_updated;
if (queue_age > MAX_AGE) {
console.log('[Flash_Alert] Stored queue is too old, discarding entire queue:', {
queue_age_ms: queue_age,
max_age_ms: MAX_AGE,
last_updated: state.last_updated,
});
Rsx_Storage.session_remove('rsx_flash_queue');
return;
}
// Filter messages: remove those past their fadeout time
const valid_messages = state.messages.filter((msg) => {
// If fadeout was scheduled and already passed, discard message
if (msg.fadeout_start_time && now >= msg.fadeout_start_time) {
console.log('[Flash_Alert] Discarding message past fadeout time:', {
message: msg.message,
fadeout_start_time: msg.fadeout_start_time,
current_time: now,
time_past_fadeout: now - msg.fadeout_start_time,
});
return false;
}
return true;
});
console.log('[Flash_Alert] Valid messages to restore:', {
count: valid_messages.length,
messages: valid_messages,
});
// Separate messages into two groups: fade-in complete vs not
const messages_to_show_immediately = [];
const messages_to_queue = [];
valid_messages.forEach((msg) => {
const message_data = {
message: msg.message,
level: msg.level,
timeout: msg.timeout,
position: msg.position,
queued_at: msg.queued_at,
fade_in_complete: msg.fade_in_complete || false,
fadeout_start_time: msg.fadeout_start_time || null,
};
if (msg.fade_in_complete) {
// Already completed fade-in - show immediately
messages_to_show_immediately.push(message_data);
} else {
// Still needs fade-in - add to queue
messages_to_queue.push(message_data);
}
// Add all to persistence queue
this._persistence_queue.push(message_data);
});
console.log('[Flash_Alert] Messages after restoration:', {
to_show_immediately: messages_to_show_immediately.length,
to_queue: messages_to_queue.length,
persistence_queue_length: this._persistence_queue.length,
});
// Re-save persistence queue to sessionStorage
this._save_queue_state();
// Ensure container is initialized before displaying
this._init();
// Display fade-in complete messages immediately (no delay, no fade-in)
// These are displayed outside the normal queue, so don't affect _is_in_progress
if (messages_to_show_immediately.length > 0) {
console.log('[Flash_Alert] Displaying fade-in complete messages immediately');
messages_to_show_immediately.forEach((msg) => {
this._display_alert(msg, true); // true = immediate display, don't block queue
});
}
// Add remaining messages to working queue for normal processing
messages_to_queue.forEach((msg) => {
this._queue.push(msg);
});
// Start queue processing for messages that still need fade-in
if (this._queue.length > 0) {
console.log('[Flash_Alert] Starting queue processing for messages needing fade-in');
if (!this._is_in_progress) {
this._process_queue();
}
}
} catch (e) {
console.error('[Flash_Alert] Failed to restore flash queue state:', e);
sessionStorage.removeItem('rsx_flash_queue');
}
}
/**
* Remove a message from persistence queue when fadeout starts
* @private
*/
static _remove_from_persistence_queue(message, level) {
const original_count = this._persistence_queue.length;
this._persistence_queue = this._persistence_queue.filter((msg) => {
return !(msg.level === level && msg.message === message);
});
console.log('[Flash_Alert] Removing message from persistence queue:', {
message: message,
level: level,
original_count: original_count,
remaining_count: this._persistence_queue.length,
});
// Save updated persistence queue to sessionStorage
this._save_queue_state();
}
/**
* Mark fade-in complete for a message in persistence queue
* @private
*/
static _mark_fade_in_complete(message, level) {
const msg = this._persistence_queue.find((m) => m.level === level && m.message === message);
if (msg) {
msg.fade_in_complete = true;
console.log('[Flash_Alert] Marked fade-in complete:', { message, level });
this._save_queue_state();
}
}
/**
* Set fadeout start time for a message in persistence queue
* @private
*/
static _set_fadeout_start_time(message, level, fadeout_start_time) {
const msg = this._persistence_queue.find((m) => m.level === level && m.message === message);
if (msg) {
msg.fadeout_start_time = fadeout_start_time;
console.log('[Flash_Alert] Set fadeout start time:', { message, level, fadeout_start_time });
this._save_queue_state();
}
}
/**
* Show a success alert
* @param {string} message - The message to display
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 4000ms)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
*/
static success(message, timeout = null, position = 'top') {
this._show(message, 'success', timeout, position);
}
/**
* Show an error alert
* @param {string} message - The message to display
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 6000ms)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
*/
static error(message, timeout = null, position = 'top') {
this._show(message, 'danger', timeout, position);
}
/**
* Show an info alert
* @param {string} message - The message to display
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 6000ms)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
*/
static info(message, timeout = null, position = 'top') {
this._show(message, 'info', timeout, position);
}
/**
* Show a warning alert
* @param {string} message - The message to display
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = default 6000ms)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
*/
static warning(message, timeout = null, position = 'top') {
this._show(message, 'warning', timeout, position);
}
/**
* Show an alert with custom level
* @param {string} message - The message to display
* @param {string} level - Alert level: 'success', 'danger', 'info', 'warning'
* @param {number|null} timeout - Auto-dismiss timeout in ms (null = use default)
* @param {string} position - Position: 'top' or 'bottom' (default: 'top')
* @private
*/
static _show(message, level = 'info', timeout = null, position = 'top') {
this._init();
// Add to BOTH queues with timestamp
const message_data = {
message,
level,
timeout,
position,
queued_at: Date.now(),
};
this._queue.push(message_data);
this._persistence_queue.push(message_data);
// Save persistence queue to sessionStorage
this._save_queue_state();
// Process queue if not already processing
if (!this._is_in_progress) {
this._process_queue();
}
}
/**
* Process the next alert in the queue
* @private
*/
static _process_queue() {
const now = Date.now();
console.log('[Flash_Alert] Processing queue:', {
working_queue_length: this._queue.length,
persistence_queue_length: this._persistence_queue.length,
is_in_progress: this._is_in_progress,
time_since_last: now - this._last_alert_time,
});
// Display next alert if enough time has passed (2.5s minimum between alerts)
if (now - this._last_alert_time >= 2500 && this._queue.length > 0) {
// Remove from working queue (shift) but stays in persistence queue until fadeout
const alert_data = this._queue.shift();
console.log('[Flash_Alert] Displaying alert from queue:', alert_data);
console.log('[Flash_Alert] Working queue after shift:', this._queue.length);
this._display_alert(alert_data);
this._last_alert_time = now;
}
// Schedule next queue check if more alerts pending
if (this._queue.length > 0) {
console.log('[Flash_Alert] Scheduling next queue check in 2.5s');
setTimeout(() => this._process_queue(), 2500);
} else {
console.log('[Flash_Alert] Working queue empty, stopping processing');
this._is_in_progress = false;
}
}
/**
* Display a single alert
* @param {Object} alert_data - Alert configuration
* @param {boolean} is_immediate - If true, don't set _is_in_progress (for restored alerts displayed outside queue)
* @private
*/
static _display_alert({ message, level, timeout, position = 'top', fade_in_complete = false, fadeout_start_time = null }, is_immediate = false) {
// Only set in_progress if this is a queued display (not an immediate restored alert)
if (!is_immediate) {
this._is_in_progress = true;
}
console.log('[Flash_Alert] Displaying alert:', {
message,
level,
fade_in_complete,
fadeout_start_time,
current_time: Date.now(),
});
// Check if an alert with the same message is already displayed
let duplicate_found = false;
this._container.find('.alert-wrapper').each(function () {
const existing_text = $(this).find('.alert').text().trim();
// Remove the close button text (×) for comparison
const existing_message = existing_text.replace(/×$/, '').trim();
if (existing_message === message) {
duplicate_found = true;
return false; // Break loop
}
});
// Skip displaying if duplicate found
if (duplicate_found) {
console.log('[Flash_Alert] Duplicate found, skipping display');
return;
}
// Create alert element
const $alert = $(`<div class="alert alert-${level} alert-dismissible fade show" role="alert">`);
// Add icon based on level
if (level === 'danger') {
$alert.append('<i class="bi bi-exclamation-circle-fill me-2"></i>');
} else if (level === 'success') {
$alert.append('<i class="bi bi-check-circle-fill me-2"></i>');
} else if (level === 'info') {
$alert.append('<i class="bi bi-info-circle-fill me-2"></i>');
} else if (level === 'warning') {
$alert.append('<i class="bi bi-exclamation-triangle-fill me-2"></i>');
}
// Add close button
const $close_button = $('<button type="button" class="btn-close" aria-label="Close"></button>');
$alert.append($close_button).append(message);
// Wrap in container for animation
const $alert_container = $('<div class="alert-wrapper">').append($alert);
// Add to floating container based on position
if (position === 'top') {
// Top position - append (newer alerts below older ones)
this._container.css('justify-content', 'flex-start').append($alert_container);
} else {
// Bottom position - prepend (newer alerts above older ones)
this._container.css('justify-content', 'flex-end').prepend($alert_container);
}
// Fade in (skip if already completed fade-in on previous page)
if (fade_in_complete) {
console.log('[Flash_Alert] Skipping fade-in, showing immediately (restored from previous page)');
$alert_container.show();
} else {
$alert_container.hide().fadeIn(400, () => {
console.log('[Flash_Alert] Fade-in complete');
// Mark fade-in complete in persistence queue
this._mark_fade_in_complete(message, level);
});
}
// Close function - fade to transparent, then slide up
const close_alert = (speed) => {
$alert_container.animate({ opacity: 0 }, speed, () => {
$alert_container.slideUp(250, () => {
$alert_container.remove();
});
});
};
// Calculate when fadeout should start
const now = Date.now();
let time_until_fadeout;
if (fadeout_start_time) {
// Honor existing fadeout schedule from restored state
time_until_fadeout = Math.max(0, fadeout_start_time - now);
console.log('[Flash_Alert] Using restored fadeout schedule:', {
fadeout_start_time,
now,
time_until_fadeout,
});
} else {
// New message - calculate fadeout time (4s for success, 6s for others)
const display_duration = level === 'success' ? 4000 : 6000;
time_until_fadeout = display_duration;
const new_fadeout_start_time = now + display_duration;
console.log('[Flash_Alert] Scheduling new fadeout:', {
display_duration,
fadeout_start_time: new_fadeout_start_time,
});
// Store fadeout start time in persistence queue
this._set_fadeout_start_time(message, level, new_fadeout_start_time);
}
// Schedule fadeout
if (time_until_fadeout >= 0) {
setTimeout(() => {
console.log('[Flash_Alert] Fadeout starting for:', message);
// Remove from persistence queue when fadeout starts
this._remove_from_persistence_queue(message, level);
close_alert(1000);
}, time_until_fadeout);
}
// Click anywhere on alert to dismiss
$alert.click(() => {
// Remove from persistence queue when manually dismissed
this._remove_from_persistence_queue(message, level);
close_alert(300);
});
}
}

View File

@@ -0,0 +1,145 @@
<?php
/**
* CODING CONVENTION:
* This file follows the coding convention where variable_names and function_names
* use snake_case (underscore_wherever_possible).
*/
namespace App\RSpade\Lib\Flash;
use App\RSpade\Core\Session\Session;
use App\RSpade\Lib\Flash\Flash_Alert_Model;
/**
* Flash_Alert - Server-side flash message system
*
* Provides a simple API for creating flash messages that will be delivered
* to the client via page rendering or Ajax responses. Messages are stored
* in the database and expire after 1 minute if not rendered.
*
* Usage:
* Flash_Alert::success('Account created successfully!');
* Flash_Alert::error('Invalid email address');
* Flash_Alert::info('Your session will expire in 5 minutes');
* Flash_Alert::warning('This action cannot be undone');
*
* Messages are automatically:
* - Included in window.rsxapp.flash_alerts on page render
* - Included in Ajax response.flash_alerts for Ajax calls
* - Displayed via Flash_Alert JavaScript class on client
* - Deleted after being retrieved for rendering
* - Expired after 1 minute if not rendered
*/
class Flash_Alert
{
/**
* Create a success flash message
*
* @param string $message The message to display
* @return void
*/
public static function success(string $message): void
{
static::_create_message($message, Flash_Alert_Model::TYPE_SUCCESS);
}
/**
* Create an error flash message
*
* @param string $message The message to display
* @return void
*/
public static function error(string $message): void
{
static::_create_message($message, Flash_Alert_Model::TYPE_ERROR);
}
/**
* Create an info flash message
*
* @param string $message The message to display
* @return void
*/
public static function info(string $message): void
{
static::_create_message($message, Flash_Alert_Model::TYPE_INFO);
}
/**
* Create a warning flash message
*
* @param string $message The message to display
* @return void
*/
public static function warning(string $message): void
{
static::_create_message($message, Flash_Alert_Model::TYPE_WARNING);
}
/**
* Get all pending flash messages for current session
*
* Returns array of messages and deletes them from database.
* Also deletes any messages older than 1 minute (expired).
*
* @return array Array of ['type' => 'success'|'error'|'info'|'warning', 'message' => '...']
*/
public static function get_pending_messages(): array
{
$session_id = Session::get_session_id();
if ($session_id === null) {
return [];
}
// Delete expired messages (older than 1 minute)
Flash_Alert_Model::where('session_id', $session_id)
->where('created_at', '<', now()->subMinute())
->delete();
// Get all pending flash alerts for this session
$alerts = Flash_Alert_Model::where('session_id', $session_id)
->orderBy('created_at', 'asc')
->get();
if ($alerts->isEmpty()) {
return [];
}
// Delete the alerts now that we're retrieving them
Flash_Alert_Model::where('session_id', $session_id)
->delete();
// Convert to client format
$messages = [];
foreach ($alerts as $alert) {
$messages[] = [
'type' => $alert->get_type_string(),
'message' => $alert->message,
];
}
return $messages;
}
/**
* Create a flash message in the database
*
* @param string $message The message text
* @param int $type_id The type ID (use Flash_Alert_Model constants)
* @return void
*/
protected static function _create_message(string $message, int $type_id): void
{
$session_id = Session::get_session_id();
if ($session_id === null) {
return;
}
$flash_alert = new Flash_Alert_Model();
$flash_alert->session_id = $session_id;
$flash_alert->type_id = $type_id;
$flash_alert->message = $message;
$flash_alert->created_at = now();
$flash_alert->save();
}
}

View File

@@ -0,0 +1,63 @@
/**
* Flash Alert Styles
*
* Styles for the floating alert notification system.
* Alerts appear centered at the top (60px down) or bottom and stack vertically.
*
* @SCSS-ANIM-01-EXCEPTION: Hover lift effect acceptable for transient notifications
*/
.floating-alert-container {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center; // Center alerts horizontally
gap: 12px;
max-width: 500px;
pointer-events: none; // Allow clicks through container
.alert-wrapper {
pointer-events: auto; // But allow clicks on individual alerts
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: auto; // Size to content, not container
min-width: 300px; // Minimum width for alerts
.alert {
margin-bottom: 0;
cursor: pointer;
user-select: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
// Icon spacing
i {
vertical-align: middle;
}
// Close button styling
.btn-close {
cursor: pointer;
}
}
}
}
// Mobile responsive adjustments
@media (max-width: 768px) {
.floating-alert-container {
left: 10px;
right: 10px;
transform: none;
max-width: none;
min-width: 0;
top: 10px;
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\RSpade\Lib\Flash;
use App\RSpade\Core\Database\Models\Rsx_Model_Abstract;
/**
* _AUTO_GENERATED_
* @property integer $id
* @property integer $session_id
* @property integer $type_id
* @property string $message
* @property \Carbon\Carbon $created_at
* @property integer $created_by
* @property integer $updated_by
* @property \Carbon\Carbon $updated_at
* @method static mixed type_id_enum()
* @method static mixed type_id_enum_select()
* @method static mixed type_id_enum_ids()
* @property-read mixed $type_id_constant
* @property-read mixed $type_id_label
* @mixin \Eloquent
*/
class Flash_Alert_Model extends Rsx_Model_Abstract
{
/** __AUTO_GENERATED: */
const TYPE_SUCCESS = 1;
const TYPE_ERROR = 2;
const TYPE_INFO = 3;
const TYPE_WARNING = 4;
/** __/AUTO_GENERATED */
// Enum constants (auto-generated by rsx:migrate:document_models)
public static $enums = [
'type_id' => [
1 => ['constant' => 'TYPE_SUCCESS', 'label' => 'Success'],
2 => ['constant' => 'TYPE_ERROR', 'label' => 'Error'],
3 => ['constant' => 'TYPE_INFO', 'label' => 'Info'],
4 => ['constant' => 'TYPE_WARNING', 'label' => 'Warning'],
],
];
public static $rel = [];
protected $table = '_flash_alerts';
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* Get the type as a string for client consumption
*
* @return string 'success'|'error'|'info'|'warning'
*/
public function get_type_string(): string
{
$type_map = [
self::TYPE_SUCCESS => 'success',
self::TYPE_ERROR => 'error',
self::TYPE_INFO => 'info',
self::TYPE_WARNING => 'warning',
];
return $type_map[$this->type_id] ?? 'info';
}
}

View File

@@ -0,0 +1,62 @@
/**
* Server_Side_Flash - Bridge between server-generated flash messages and Flash_Alert display
*
* Processes flash_alerts arrays from server (page renders and Ajax responses)
* and displays them using the Flash_Alert component.
*
* Integration points:
* - Page render: window.rsxapp.flash_alerts populated by bundle renderer
* - Ajax responses: response.flash_alerts added to Ajax success/error responses
*
* Initialization:
* - Automatically called on framework init hook
* - Restores persisted queue state from sessionStorage before processing server messages
*/
class Server_Side_Flash {
/**
* Framework initialization hook
* Restores queue state and processes server flash messages
* @private
*/
static _on_framework_core_init() {
console.log('[Server_Side_Flash] Framework init hook called');
// Restore any persisted queue state from sessionStorage first
console.log('[Server_Side_Flash] Calling Flash_Alert._restore_queue_state()');
Flash_Alert._restore_queue_state();
// Then process server-generated flash messages
const has_server_messages = !!(window.rsxapp && window.rsxapp.flash_alerts);
console.log('[Server_Side_Flash] Checking for server messages:', {
has_rsxapp: !!window.rsxapp,
has_flash_alerts: has_server_messages,
flash_alerts: window.rsxapp?.flash_alerts
});
if (has_server_messages) {
console.log('[Server_Side_Flash] Processing server messages');
Server_Side_Flash.process(window.rsxapp.flash_alerts);
}
}
/**
* Process an array of flash alert messages
* @param {Array} flash_alerts - Array of {type: string, message: string} objects
*/
static process(flash_alerts) {
if (!Array.isArray(flash_alerts)) {
return;
}
flash_alerts.forEach((alert) => {
const method = alert.type; // 'success', 'error', 'info', 'warning'
// Call Flash_Alert method if it exists
if (Flash_Alert[method] && typeof Flash_Alert[method] === 'function') {
Flash_Alert[method](alert.message);
} else {
console.error('Unknown flash alert type:', alert.type);
}
});
}
}

View File

@@ -0,0 +1 @@
[2025-11-14 01:30:33] rsx:refactor:rename_php_class Flash Flash_Alert

View File

@@ -0,0 +1,135 @@
<?php
namespace App\RSpade\Services;
use App\RSpade\Core\Service\Rsx_Service_Abstract;
use App\RSpade\Core\Task\Task_Instance;
use App\RSpade\Core\Task\Task_Attribute;
use App\RSpade\Core\Task\Schedule;
use App\RSpade\Core\Task\Task_Status;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* Cleanup Service
*
* System maintenance tasks that run on a schedule.
* Handles cleanup of old data and temporary files.
*/
class Cleanup_Service extends Rsx_Service_Abstract
{
/**
* Clean up old completed/failed tasks
*
* Deletes task records older than the retention period (default: 30 days)
* Keeps pending/running tasks regardless of age.
*
* Runs daily at 3 AM
*/
#[Task_Attribute('Clean up old completed and failed task records')]
#[Schedule('0 3 * * *')]
public static function cleanup_old_tasks(Task_Instance $task, array $params = []): array
{
$retention_days = config('rsx.tasks.task_retention_days', 30);
$cutoff_date = now()->subDays($retention_days);
$task->info("Cleaning up tasks older than {$retention_days} days (before {$cutoff_date})");
// Delete old completed tasks
$deleted_completed = DB::table('_tasks')
->where('status', Task_Status::COMPLETED)
->where('completed_at', '<', $cutoff_date)
->delete();
$task->info("Deleted {$deleted_completed} completed task(s)");
// Delete old failed tasks
$deleted_failed = DB::table('_tasks')
->where('status', Task_Status::FAILED)
->where('completed_at', '<', $cutoff_date)
->delete();
$task->info("Deleted {$deleted_failed} failed task(s)");
$total_deleted = $deleted_completed + $deleted_failed;
return [
'deleted_completed' => $deleted_completed,
'deleted_failed' => $deleted_failed,
'total_deleted' => $total_deleted,
'retention_days' => $retention_days,
];
}
/**
* Clean up orphaned task temporary directories
*
* Removes temp directories for tasks that no longer exist or are completed.
* Checks the temp_expires_at timestamp for cleanup eligibility.
*
* Runs every hour
*/
#[Task_Attribute('Clean up orphaned task temporary directories')]
#[Schedule('0 * * * *')]
public static function cleanup_temp_directories(Task_Instance $task, array $params = []): array
{
$base_temp_dir = storage_path('rsx-tmp/tasks');
if (!is_dir($base_temp_dir)) {
$task->info("Temp directory does not exist: {$base_temp_dir}");
return ['directories_removed' => 0];
}
$task->info("Scanning temp directory: {$base_temp_dir}");
$directories_removed = 0;
$directories = File::directories($base_temp_dir);
foreach ($directories as $dir) {
$dir_name = basename($dir);
// Check if it's an immediate task temp dir (these should be cleaned up by the task itself)
if (str_starts_with($dir_name, 'immediate_')) {
// Check if directory is old (more than 1 hour)
$dir_time = filemtime($dir);
if (time() - $dir_time > 3600) {
$task->info("Removing old immediate temp directory: {$dir_name}");
File::deleteDirectory($dir);
$directories_removed++;
}
continue;
}
// Extract task ID from directory name (format: task_123)
if (preg_match('/^task_(\d+)$/', $dir_name, $matches)) {
$task_id = (int) $matches[1];
// Check if task still exists
$task_row = DB::table('_tasks')->where('id', $task_id)->first();
if (!$task_row) {
// Task doesn't exist, remove directory
$task->info("Removing orphaned temp directory for deleted task: {$dir_name}");
File::deleteDirectory($dir);
$directories_removed++;
continue;
}
// Check if task is completed/failed
if (in_array($task_row->status, [Task_Status::COMPLETED, Task_Status::FAILED])) {
$task->info("Removing temp directory for completed/failed task: {$dir_name}");
File::deleteDirectory($dir);
$directories_removed++;
continue;
}
}
}
$task->info("Removed {$directories_removed} temp director(ies)");
return [
'directories_removed' => $directories_removed,
'total_directories_scanned' => count($directories),
];
}
}

View File

@@ -473,6 +473,10 @@ function rsx_view($id, $data = [], $merge_data = [])
// Handle different view locations
if (str_starts_with($view_name, 'resources/views/')) {
$view_name = substr($view_name, strlen('resources/views/'));
} elseif (str_starts_with($view_name, 'app/RSpade/')) {
// For framework views, use namespace format
// The namespace 'rspade' is registered in Rsx_Framework_Provider
$view_name = 'rspade::' . substr($view_name, strlen('app/RSpade/'));
} elseif (str_starts_with($view_name, 'rsx/')) {
// For RSX views, use namespace format
// The namespace 'rsx' is registered in RsxServiceProvider
@@ -1389,3 +1393,101 @@ function duration_to_human($seconds, $round_to_whole_value = false)
return count($parts) > 1 ? $parts[0] . " and " . $parts[1] : (count($parts) > 0 ? $parts[0] : "less than a second");
}
}
/**
* Convert a full URL to short URL by removing protocol
*
* Strips http:// or https:// from the beginning of the URL if present.
* Leaves the URL alone if it doesn't start with either protocol.
* Removes trailing slash if there is no path.
*
* @param string|null $url URL to convert
* @return string|null Short URL without protocol
*/
function full_url_to_short_url(?string $url): ?string
{
if ($url === null || $url === '') {
return $url;
}
// Remove http:// or https:// from the beginning
if (stripos($url, 'http://') === 0) {
$url = substr($url, 7);
} elseif (stripos($url, 'https://') === 0) {
$url = substr($url, 8);
}
// Remove trailing slash if there is no path (just domain)
// Check if URL is just domain with trailing slash (no path after slash)
if (substr($url, -1) === '/' && substr_count($url, '/') === 1) {
$url = rtrim($url, '/');
}
return $url;
}
/**
* Convert a short URL to full URL by adding protocol
*
* Adds http:// to the beginning of the URL if it lacks a protocol.
* Leaves URLs with existing http:// or https:// unchanged.
* Adds trailing slash if there is no path.
*
* @param string|null $url URL to convert
* @return string|null Full URL with protocol
*/
function short_url_to_full_url(?string $url): ?string
{
if ($url === null || $url === '') {
return $url;
}
// Check if URL already has a protocol
if (stripos($url, 'http://') === 0 || stripos($url, 'https://') === 0) {
$full_url = $url;
} else {
// Add http:// protocol
$full_url = 'http://' . $url;
}
// Add trailing slash if there is no path (just domain)
// Check if URL has no slash after the domain
$without_protocol = preg_replace('#^https?://#i', '', $full_url);
if (strpos($without_protocol, '/') === false) {
$full_url .= '/';
}
return $full_url;
}
/**
* Validate a short URL format
*
* Validates that a URL (without protocol) looks like a valid domain.
* Requirements:
* - Must contain at least one dot (.)
* - Must not contain spaces
* - Empty strings are considered valid (optional field)
*
* @param string|null $url Short URL to validate
* @return bool True if valid, false otherwise
*/
function validate_short_url(?string $url): bool
{
// Empty strings are valid (optional field)
if ($url === null || $url === '') {
return true;
}
// Must not contain spaces
if (strpos($url, ' ') !== false) {
return false;
}
// Must contain at least one dot (domain.extension)
if (strpos($url, '.') === false) {
return false;
}
return true;
}

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

Some files were not shown because too many files have changed in this diff Show More