Add model constant export to JS, rsxapp hydration, on_stop lifecycle

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-12-10 09:08:20 +00:00
parent 611e269465
commit d047b49d39
58 changed files with 207 additions and 58 deletions

View File

@@ -127,21 +127,8 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
// Only regenerate if source is newer than stub
if ($stub_mtime >= $source_mtime) {
// Also check if the model metadata has changed
// by comparing a hash of enums, relationships, and columns
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
// by comparing a hash of enums, relationships, columns, and constants
$model_metadata = static::_get_model_metadata_for_hash($fqcn, $class_name, $manifest_data);
$model_metadata_hash = md5(json_encode($model_metadata));
$old_metadata_hash = $metadata['model_metadata_hash'] ?? '';
@@ -164,21 +151,7 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
// Store the metadata hash for future comparisons if not already done
if (!isset($manifest_data['data']['files'][$file_path]['model_metadata_hash'])) {
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
$model_metadata = static::_get_model_metadata_for_hash($fqcn, $class_name, $manifest_data);
$manifest_data['data']['files'][$file_path]['model_metadata_hash'] = md5(json_encode($model_metadata));
}
}
@@ -262,6 +235,46 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
return false;
}
/**
* Get model metadata for hash comparison (detects when stubs need regeneration)
*
* @param string $fqcn Fully qualified class name
* @param string $class_name Simple class name
* @param array $manifest_data The manifest data array
* @return array Metadata array for hashing
*/
private static function _get_model_metadata_for_hash(string $fqcn, string $class_name, array $manifest_data): array
{
$model_metadata = [];
// Get relationships
$model_metadata['rel'] = $fqcn::get_relationships();
// Get enums
if (property_exists($fqcn, 'enums')) {
$model_metadata['enums'] = $fqcn::$enums ?? [];
}
// Get columns from models metadata if available
if (isset($manifest_data['data']['models'][$class_name]['columns'])) {
$model_metadata['columns'] = $manifest_data['data']['models'][$class_name]['columns'];
}
// Get public constants defined directly on this class
$reflection = new \ReflectionClass($fqcn);
$constants = [];
foreach ($reflection->getReflectionConstants(\ReflectionClassConstant::IS_PUBLIC) as $const) {
if ($const->getDeclaringClass()->getName() === $fqcn) {
$constants[$const->getName()] = $const->getValue();
}
}
if (!empty($constants)) {
$model_metadata['constants'] = $constants;
}
return $model_metadata;
}
/**
* Sanitize model name for use as filename
*/
@@ -314,6 +327,32 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
$js_model_base_class = config('rsx.js_model_base_class');
$extends_class = $js_model_base_class ?: 'Rsx_Js_Model';
// Collect enum constant names to avoid duplicating them
$enum_constant_names = [];
foreach ($enums as $column => $enum_values) {
foreach ($enum_values as $value => $props) {
if (!empty($props['constant'])) {
$enum_constant_names[] = $props['constant'];
}
}
}
// Get all public constants defined directly on this model class (not inherited)
$reflection = new \ReflectionClass($fqcn);
$non_enum_constants = [];
foreach ($reflection->getReflectionConstants(\ReflectionClassConstant::IS_PUBLIC) as $const) {
// Only include constants defined directly on this class
if ($const->getDeclaringClass()->getName() !== $fqcn) {
continue;
}
$const_name = $const->getName();
// Skip constants already generated from enums
if (in_array($const_name, $enum_constant_names)) {
continue;
}
$non_enum_constants[$const_name] = $const->getValue();
}
// Start building the stub content
$content = "/**\n";
$content .= " * Auto-generated JavaScript stub for {$class_name}\n";
@@ -326,6 +365,16 @@ class Database_BundleIntegration extends BundleIntegration_Abstract
// Add static __MODEL property for PHP model name resolution
$content .= " static __MODEL = '{$class_name}';\n\n";
// Generate non-enum constants first (static properties)
if (!empty($non_enum_constants)) {
$content .= " // Non-enum constants\n";
foreach ($non_enum_constants as $const_name => $const_value) {
$value_json = json_encode($const_value);
$content .= " static {$const_name} = {$value_json};\n";
}
$content .= "\n";
}
// Generate enum constants and methods
foreach ($enums as $column => $enum_values) {
// Sort enum values by order property first, then by key

View File

@@ -222,6 +222,24 @@ class Rsx {
return !window.rsxapp.debug;
}
/**
* Get the current logged-in user model instance
* Returns the hydrated ORM model if available, or the raw data object
* @returns {Rsx_Js_Model|Object|null} User model instance or null if not logged in
*/
static user() {
return window.rsxapp?.user || null;
}
/**
* Get the current site model instance
* Returns the hydrated ORM model if available, or the raw data object
* @returns {Rsx_Js_Model|Object|null} Site model instance or null if not set
*/
static site() {
return window.rsxapp?.site || null;
}
// Generates a unique number for the application instance
static uid() {
if (typeof Rsx._uid == undef) {
@@ -601,6 +619,43 @@ class Rsx {
}
}
/**
* Hydrate rsxapp.user and rsxapp.site into ORM model instances
*
* Checks if window.rsxapp.user and window.rsxapp.site contain raw data objects
* with __MODEL markers, and if the corresponding model classes are available,
* replaces them with proper ORM instances.
*
* This enables code like:
* const user = Rsx.user();
* await user.some_relationship(); // Works because user is a proper model instance
*/
static _hydrate_rsxapp_models() {
if (!window.rsxapp) {
return;
}
// Hydrate user if present and has __MODEL marker
if (window.rsxapp.user && window.rsxapp.user.__MODEL) {
const UserClass = Manifest.get_class_by_name(window.rsxapp.user.__MODEL);
// Check class exists and extends Rsx_Js_Model - @JS-DEFENSIVE-01-EXCEPTION - dynamic model resolution
if (UserClass && Manifest.js_is_subclass_of(UserClass, Rsx_Js_Model)) {
window.rsxapp.user = new UserClass(window.rsxapp.user);
console_debug('RSX_INIT', `Hydrated rsxapp.user as ${window.rsxapp.user.__MODEL}`);
}
}
// Hydrate site if present and has __MODEL marker
if (window.rsxapp.site && window.rsxapp.site.__MODEL) {
const SiteClass = Manifest.get_class_by_name(window.rsxapp.site.__MODEL);
// Check class exists and extends Rsx_Js_Model - @JS-DEFENSIVE-01-EXCEPTION - dynamic model resolution
if (SiteClass && Manifest.js_is_subclass_of(SiteClass, Rsx_Js_Model)) {
window.rsxapp.site = new SiteClass(window.rsxapp.site);
console_debug('RSX_INIT', `Hydrated rsxapp.site as ${window.rsxapp.site.__MODEL}`);
}
}
}
/**
* Internal: Execute multi-phase initialization for all registered classes
* This runs various initialization phases in order to properly set up the application
@@ -617,6 +672,10 @@ class Rsx {
// Setup exception handlers first, before any initialization phases
Rsx._setup_exception_handlers();
// Hydrate rsxapp.user and rsxapp.site into ORM model instances
// This must happen early, before any code tries to use these objects
Rsx._hydrate_rsxapp_models();
// Get all registered classes from the manifest
const all_classes = Manifest.get_all_classes();
@@ -697,7 +756,6 @@ class Rsx {
y: window.scrollY
};
sessionStorage.setItem(Rsx._SCROLL_STORAGE_KEY, JSON.stringify(scroll_data));
console.log('[Rsx Scroll] Saved:', scroll_data.x, scroll_data.y, 'for', scroll_data.url);
}, 100); // 100ms debounce
}
@@ -707,61 +765,46 @@ class Rsx {
* @private
*/
static _restore_scroll_on_refresh() {
console.log('[Rsx Scroll] _restore_scroll_on_refresh called');
// Set up scroll listener to continuously save position
window.addEventListener('scroll', Rsx._save_scroll_position, { passive: true });
console.log('[Rsx Scroll] Scroll listener attached');
// Check if this is a page refresh using Performance API
const nav_entries = performance.getEntriesByType('navigation');
console.log('[Rsx Scroll] Navigation entries:', nav_entries.length);
if (nav_entries.length === 0) {
console.log('[Rsx Scroll] No navigation entries found, skipping restore');
return;
}
const nav_type = nav_entries[0].type;
console.log('[Rsx Scroll] Navigation type:', nav_type);
if (nav_type !== 'reload') {
console.log('[Rsx Scroll] Not a reload (type=' + nav_type + '), skipping restore');
return;
}
// This is a refresh - try to restore scroll position
const stored = sessionStorage.getItem(Rsx._SCROLL_STORAGE_KEY);
console.log('[Rsx Scroll] Stored scroll data:', stored);
if (!stored) {
console.log('[Rsx Scroll] No stored scroll position found');
return;
}
try {
const scroll_data = JSON.parse(stored);
const current_url = window.location.pathname + window.location.search;
console.log('[Rsx Scroll] Stored URL:', scroll_data.url, 'Current URL:', current_url);
// Only restore if URL matches
if (scroll_data.url !== current_url) {
console.log('[Rsx Scroll] URL mismatch, skipping restore');
return;
}
// Restore scroll position instantly
console.log('[Rsx Scroll] Restoring scroll to:', scroll_data.x, scroll_data.y);
window.scrollTo({
left: scroll_data.x,
top: scroll_data.y,
behavior: 'instant'
});
console.log('[Rsx Scroll] Restored scroll position on refresh:', scroll_data.x, scroll_data.y);
// Clear stored position after successful restore
sessionStorage.removeItem(Rsx._SCROLL_STORAGE_KEY);
} catch (e) {
// Invalid JSON or other error - ignore
console.log('[Rsx Scroll] Error restoring scroll:', e.message);
sessionStorage.removeItem(Rsx._SCROLL_STORAGE_KEY);
}
}