diff --git a/.github/workflows/runtime-regressions.yml b/.github/workflows/runtime-regressions.yml new file mode 100644 index 0000000..035048a --- /dev/null +++ b/.github/workflows/runtime-regressions.yml @@ -0,0 +1,29 @@ +name: Runtime regressions +on: [push, pull_request] +jobs: + runtime: + runs-on: ubuntu-latest + strategy: + matrix: + php: ['8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: evolution-cms/evolution + ref: 3.6.x + path: .test-core + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, dom, xml, sqlite3, pdo_sqlite, gd, intl, zip + coverage: none + - run: composer install --working-dir=.test-core/core --no-interaction --no-scripts --prefer-dist + - name: Run package regression scripts + env: + SGALLERY_AUTOLOAD: ${{ github.workspace }}/.test-core/core/vendor/autoload.php + SCOMMERCE_TEST_AUTOLOAD: ${{ github.workspace }}/.test-core/core/vendor/autoload.php + PACKAGE_AUTOLOAD: ${{ github.workspace }}/.test-core/core/vendor/autoload.php + run: | + for test in tests/runtime/*-test.php; do php "$test"; done + php tests/resource-aliases.php diff --git a/plugins/sMultisitePlugin.php b/plugins/sMultisitePlugin.php index 7599a41..93b6c47 100644 --- a/plugins/sMultisitePlugin.php +++ b/plugins/sMultisitePlugin.php @@ -32,21 +32,17 @@ * migrations have created the table. SQLite fails immediately in that * state, so runtime listeners must stay in default-mode until migrations * finish. - * The result is memoized for the current PHP request to avoid repeated - * information_schema queries from product-card rendering. + * Positive checks use a database-scoped shared cache invalidated by + * migrations and cache flush. Missing tables are never cached, so + * installation can create the table and retry in the same PHP process. * * @return bool */ function ms_multisite_table_ready(): bool { - static $ready; - if (isset($ready)) { - return $ready; - } - try { - return $ready = Schema::hasTable('s_multisites'); + return \Seiger\sMultisite\Support\ActiveDomainCache::tableReady(); } catch (\Throwable) { - return $ready = false; + return false; } } } @@ -212,7 +208,7 @@ function ms_sso_current_session_id(): string { * @return void */ Event::listen('evolution.OnLoadSettings', function($params) { - $host = $_SERVER['HTTP_HOST']; + $host = $_SERVER['HTTP_HOST'] ?? null; if (isset($params['config']['setHost']) && trim($params['config']['setHost']) !== '') { $host = trim($params['config']['setHost']); } @@ -222,7 +218,7 @@ function ms_sso_current_session_id(): string { if (!ms_multisite_table_ready()) { return; } - $domain = \Seiger\sMultisite\Models\sMultisite::whereDomain($host)->whereActive(1)->first(); + $domain = \Seiger\sMultisite\Support\ActiveDomainCache::find($host); if ($domain) { evo()->setConfig('site_key', $domain->key); evo()->setConfig('site_name', $domain->site_name); diff --git a/src/Support/ActiveDomainCache.php b/src/Support/ActiveDomainCache.php new file mode 100644 index 0000000..150cb1c --- /dev/null +++ b/src/Support/ActiveDomainCache.php @@ -0,0 +1,155 @@ +|null Isolate ephemeral SQLite databases. */ + private static ?\WeakMap $memoryDatabases = null; + + /** + * Scope generations to the actual database and prefixed package table. + * + * @param \Illuminate\Database\Connection $connection + * @return string + * @since 1.2.0 + */ + private static function key($connection): string + { + $database = (string)$connection->getDatabaseName(); + $memoryScope = null; + if ($connection->getDriverName() === 'sqlite' + && ($database === '' || str_contains($database, ':memory:') || str_contains($database, 'mode=memory'))) { + self::$memoryDatabases ??= new \WeakMap(); + $memoryScope = self::$memoryDatabases[$connection] ??= bin2hex(random_bytes(16)); + } + return 'sMultisite.active-domain.v1.' . hash('sha256', serialize([ + $connection->getName(), $connection->getConfig('driver'), $connection->getConfig('host'), + $connection->getConfig('port'), $connection->getConfig('unix_socket'), + $connection->getDatabaseName(), $connection->getTablePrefix(), $memoryScope, + ])); + } + + /** + * Observe model, builder and raw SQL writes, including comments and CTE prefixes. + * Clear again after the outer transaction ends to discard concurrent old reads. + * + * @param \Illuminate\Contracts\Events\Dispatcher $events + * @return void + * @since 1.2.0 + */ + public static function listen($events): void + { + $dirty = []; + foreach ([\Illuminate\Database\Events\MigrationEnded::class, + \Illuminate\Database\Events\MigrationsEnded::class] as $event) { + $events->listen($event, static function (): void { + $connection = (new sMultisite())->getConnection(); + Cache::forget(self::key($connection) . '.table_ready'); + Cache::forget(self::key($connection)); + }); + } + $events->listen(QueryExecuted::class, static function ($event) use (&$dirty): void { + $table = preg_quote($event->connection->getTablePrefix() . 's_multisites', '~'); + if (!preg_match('~\b(?:insert|update|delete|replace|truncate|create|alter|drop|rename)\b~i', $event->sql) + || !preg_match('~(?sql)) { + return; + } + if (preg_match('~\b(?:create|alter|drop|rename)\b~i', $event->sql)) { + Cache::forget(self::key($event->connection) . '.table_ready'); + } + Cache::forget(self::key($event->connection)); + if ($event->connection->transactionLevel() > 0) { + $dirty[spl_object_id($event->connection)] = true; + } + }); + foreach ([TransactionCommitted::class, TransactionRolledBack::class] as $event) { + $events->listen($event, static function ($event) use (&$dirty): void { + $id = spl_object_id($event->connection); + if (isset($dirty[$id]) && $event->connection->transactionLevel() === 0) { + Cache::forget(self::key($event->connection)); + Cache::forget(self::key($event->connection) . '.table_ready'); + unset($dirty[$id]); + } + }); + } + } + + /** + * Cache only a successful schema check, scoped to the model's database. + * Missing tables are checked again on the next call, including installation + * in the same process. Transactions never publish uncommitted schema state. + * Read the shared readiness entry on each call so direct Cache::flush() takes + * effect even when the backend emits no flush event. Migrations and observed + * DDL remove the positive entry; TTL bounds externally changed schemas. + * + * @return bool Whether the multisite table currently exists. + * @since 1.2.0 + */ + public static function tableReady(): bool + { + $connection = (new sMultisite())->getConnection(); + $key = self::key($connection) . '.table_ready'; + $transaction = $connection->transactionLevel() > 0; + if (!$transaction && Cache::get($key) === true) { + return true; + } + $ready = $connection->getSchemaBuilder()->hasTable('s_multisites'); + if ($ready && !$transaction) { + Cache::put($key, true, 300); + } + return $ready; + } + + /** + * Resolve the caller's unchanged host, including OnLoadSettings setHost overrides. + * Cache misses in an envelope; bypass shared state inside transactions. A five + * minute TTL also bounds staleness for writes made outside this application. + * + * CLI may have no host: retain the original whereDomain(null) lookup and + * bypass shared host caching rather than treating null as an empty domain. + * Custom global scopes and retrieved observers retain their original database path. + * + * @param string|null $host Exact host used by the existing domain query, or null in CLI. + * @return sMultisite|null + * @since 1.2.0 + */ + public static function find(?string $host): ?sMultisite + { + $model = new sMultisite(); + $connection = $model->getConnection(); + $read = static fn () => sMultisite::whereDomain($host)->whereActive(1)->first(); + // Custom scopes/observers may depend on context outside the exact host. + $events = $model->getEventDispatcher(); + if ($host === null || $connection->transactionLevel() > 0 + || $model->getGlobalScopes() !== [] + || ($events && $events->hasListeners('eloquent.retrieved: ' . sMultisite::class))) { + return $read(); + } + + $key = self::key($connection); + $generation = Cache::remember($key, 300, static fn () => bin2hex(random_bytes(16))); + $entryKey = $key . '.' . $generation . '.' . hash('sha256', $host); + $entry = Cache::get($entryKey); + if (is_array($entry) && array_key_exists('attributes', $entry)) { + return $entry['attributes'] === null ? null + : $model->newFromBuilder($entry['attributes'], $connection->getName()); + } + + // Return the queried model on a miss so retrieved observers fire only once. + $domain = $read(); + Cache::put($entryKey, ['attributes' => $domain?->getRawOriginal()], 300); + return $domain; + } + +} diff --git a/src/sMultisiteServiceProvider.php b/src/sMultisiteServiceProvider.php index d85dd7c..16eddaa 100644 --- a/src/sMultisiteServiceProvider.php +++ b/src/sMultisiteServiceProvider.php @@ -73,12 +73,14 @@ public function boot() /** * Register bindings and services in the container. * - * This method is used to bind classes or interfaces into the service container. + * Bindings retain the existing package lifecycle; domain cache invalidation observes + * writes from manager, frontend, migrations and CLI before plugins run. * * @return void */ public function register() { + \Seiger\sMultisite\Support\ActiveDomainCache::listen($this->app['events']); // Add plugins to Evolution CMS $this->loadPluginsFrom(dirname(__DIR__) . '/plugins/'); $this->app->singleton(sMultisite::class); diff --git a/tests/runtime/README.md b/tests/runtime/README.md new file mode 100644 index 0000000..028d080 --- /dev/null +++ b/tests/runtime/README.md @@ -0,0 +1,7 @@ +# Runtime regression checks + +Run each `*-test.php` with `PACKAGE_AUTOLOAD` pointing at an installed Evolution CMS 3.6.x `core/vendor/autoload.php`. The test bootstrap loads this package from the checkout ahead of any consumer classmap. Fixtures use isolated SQLite databases and temporary files; no site database is used. The CI matrix executes PHP 8.4 and 8.5. + +Shared entries use a five-minute TTL. Observed application writes invalidate immediately; writes made directly outside the application require a cache clear for immediate visibility. Transactions bypass shared entries and invalidate again after the outer commit/rollback. + +Custom model scopes and retrieved observers keep the uncached database path, because they may depend on state not represented by the package cache key. diff --git a/tests/runtime/bootstrap.php b/tests/runtime/bootstrap.php new file mode 100644 index 0000000..8715379 --- /dev/null +++ b/tests/runtime/bootstrap.php @@ -0,0 +1,24 @@ +instance('events', $events); +$app->instance('cache', new Illuminate\Cache\Repository(new Illuminate\Cache\FileStore(new Illuminate\Filesystem\Filesystem(), sys_get_temp_dir() . '/ewt-domain-cache-check-' . getmypid()))); +$db = new Illuminate\Database\Capsule\Manager(); +$db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'evo_']); +$db->setEventDispatcher($events); +$db->setAsGlobal(); +$db->bootEloquent(); +$c = $db->getConnection(); +$c->statement('CREATE TABLE evo_s_multisites (id INTEGER PRIMARY KEY, domain TEXT, active INTEGER, resource INTEGER, "key" TEXT, site_name TEXT, site_start INTEGER, error_page INTEGER, unauthorized_page INTEGER, site_color TEXT, created_at TEXT, updated_at TEXT)'); +C::listen($events); +foreach (['a.test', 'b.test'] as $i => $host) { + $c->table('s_multisites')->insert(['id' => $i + 1, 'domain' => $host, 'active' => 1, 'resource' => $i + 10, 'key' => 'site' . $i, 'site_name' => 'Site ' . $i, 'site_start' => $i + 20, 'error_page' => 4, 'unauthorized_page' => 5, 'site_color' => 'blue']); +} +/** + * Support isolated package assertions without a site bootstrap. + * + * @since 1.2.0 + */ +function check($ok, $label) +{ + if (!$ok) { + throw new RuntimeException($label); + } + echo "OK {$label}\n"; +} +$c->enableQueryLog(); +check(C::find('a.test')->site_start === 20, 'cold lookup and integer casts'); +$c->flushQueryLog(); +check(C::find('a.test')->key === 'site0' && count($c->getQueryLog()) === 0, 'warm lookup skips SQL'); +$retrieved = 0; +M::retrieved(static function () use (&$retrieved): void { + ++$retrieved; +}); +C::find('a.test'); +C::find('a.test'); +check($retrieved === 2, 'retrieved observers run once per lookup'); +$events->forget('eloquent.retrieved: ' . M::class); +$decorate = true; +M::retrieved(static function ($model) use (&$decorate): void { + if ($decorate) { + $model->site_name = 'decorated:' . $model->site_name; + } +}); +Cache::flush(); +$firstDecorated = C::find('a.test')->site_name; +check($firstDecorated === 'decorated:Site 0' && C::find('a.test')->site_name === $firstDecorated, 'retrieved observer transformations run once on original attributes'); +$decorate = false; +$events->forget('eloquent.retrieved: ' . M::class); +$c->statement("/* manager edit */ UPDATE evo_s_multisites SET site_name = 'Commented' WHERE id = 1"); +check(C::find('a.test')->site_name === 'Commented', 'comment-prefixed writes invalidate'); +$c->statement("WITH chosen AS (SELECT 1 AS id) UPDATE evo_s_multisites SET site_name = 'CTE' WHERE id IN (SELECT id FROM chosen)"); +check(C::find('a.test')->site_name === 'CTE', 'CTE-prefixed writes invalidate'); +$scopes = M::getAllGlobalScopes(); +M::addGlobalScope('deny', static fn($query) => $query->whereRaw('0 = 1')); +check(C::find('a.test') === null, 'custom scopes bypass shared domain entries'); +M::setAllGlobalScopes($scopes); +C::find('missing.test'); +$c->flushQueryLog(); +check(C::find('missing.test') === null && count($c->getQueryLog()) === 0, 'missing domain cached'); +$c->table('s_multisites')->where('id', 1)->update(['domain' => 'renamed.test', 'site_name' => 'Renamed']); +check(C::find('a.test') === null && C::find('renamed.test')->site_name === 'Renamed', 'rename and fields invalidated'); +$c->table('s_multisites')->where('id', 1)->update(['active' => 0]); +check(C::find('renamed.test') === null, 'deactivation invalidated'); +$c->table('s_multisites')->where('id', 1)->update(['active' => 1]); +check(C::find('renamed.test') !== null, 'activation invalidated'); +$c->beginTransaction(); +$c->table('s_multisites')->where('id', 1)->update(['site_name' => 'Uncommitted']); +check(C::find('renamed.test')->site_name === 'Uncommitted', 'transaction bypass'); +$c->rollBack(); +check(C::find('renamed.test')->site_name === 'Renamed', 'rollback invalidation'); +$c->beginTransaction(); +$c->table('s_multisites')->where('id', 1)->update(['site_name' => 'Committed']); +$c->commit(); +check(C::find('renamed.test')->site_name === 'Committed', 'commit invalidation'); +$m = M::find(1); +$m->site_name = 'Model update'; +$m->save(); +check(C::find('renamed.test')->site_name === 'Model update', 'model editing invalidation'); +$m->delete(); +check(C::find('renamed.test') === null, 'model deletion invalidation'); +$evo = new class +{ + public array $config = []; + /** + * Record settings written by the actual package listener. + * + * @since 1.2.0 + */ + function setConfig($k, $v) + { + $this->config[$k] = $v; + } + /** + * Expose only the controlled configuration used by this isolated regression. + * + * @since 1.2.0 + */ + function getConfig($k, $default = null) + { + return $this->config[$k] ?? $default; + } +}; +// Exercise the actual listener; the URL facade is unused when no root binding exists. +$processor = new stdClass(); +$app->instance('UrlProcessor', $processor); +$source = file_get_contents(dirname(__DIR__, 2) . '/plugins/sMultisitePlugin.php'); +$start = strpos($source, "Event::listen('evolution.OnLoadSettings'"); +$end = strpos($source, "\n});", $start) + 4; +$listener = substr($source, $start, $end - $start); +eval('use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Cache; use EvolutionCMS\Facades\UrlProcessor;' . $listener); +$_SERVER['HTTP_HOST'] = 'unknown.test'; +$params = ['config' => ['setHost' => ' b.test ']]; +$events->dispatch('evolution.OnLoadSettings', [$params]); +check($evo->config['site_key'] === 'site1' && $evo->config['site_start'] === 21, 'setHost selects trimmed override'); +$events->dispatch('evolution.OnLoadSettings', [['config' => []]]); +check($evo->config['site_key'] === 'default' && $evo->config['site_root'] === 0, 'unknown host preserves defaults'); +echo "ALL DOMAIN CACHE CHECKS PASSED\n"; +unset($_SERVER['HTTP_HOST']); +$events->dispatch('evolution.OnLoadSettings', [['config' => []]]); +check($evo->config['site_key'] === 'default', 'CLI missing host preserves defaults'); +$events->dispatch('evolution.OnLoadSettings', [['config' => ['setHost' => ' b.test ']]]); +check($evo->config['site_key'] === 'site1', 'CLI setHost override still works'); +$c->table('s_multisites')->insert(['id' => 3, 'domain' => null, 'active' => 1, 'key' => 'null-domain']); +$c->table('s_multisites')->insert(['id' => 4, 'domain' => '', 'active' => 1, 'key' => 'empty-domain']); +check(C::find(null)?->key === 'null-domain', 'null keeps original whereDomain(null) semantics'); +check(C::find('')?->key === 'empty-domain', 'empty host remains distinct from null'); +$c->flushQueryLog(); +C::find(null); +check(count($c->getQueryLog()) === 1, 'hostless lookup bypasses shared host cache'); +$c->flushQueryLog(); +check(C::tableReady(), 'cold table check'); +$c->flushQueryLog(); +check(C::tableReady() && count($c->getQueryLog()) === 0, 'warm table check skips SQL'); +$events->dispatch(new Illuminate\Database\Events\MigrationsEnded('up')); +$c->flushQueryLog(); +C::tableReady(); +check(count($c->getQueryLog()) > 0, 'migration completion invalidates'); +$events->dispatch(new Illuminate\Database\Events\MigrationEnded(new class extends Illuminate\Database\Migrations\Migration +{ +}, 'down')); +$c->flushQueryLog(); +C::tableReady(); +check(count($c->getQueryLog()) > 0, 'single migration completion invalidates'); +Illuminate\Support\Facades\Cache::flush(); +$c->flushQueryLog(); +C::tableReady(); +check(count($c->getQueryLog()) > 0, 'cache flush invalidates'); +$c->beginTransaction(); +$c->statement('DROP TABLE evo_s_multisites'); +check(!C::tableReady(), 'uncommitted drop bypasses positive cache'); +$c->rollBack(); +check(C::tableReady(), 'rollback restores availability'); +$c->statement('DROP TABLE evo_s_multisites'); +$c->flushQueryLog(); +check(!C::tableReady() && !C::tableReady() && count($c->getQueryLog()) >= 2, 'negative result not cached'); +$c->statement('CREATE TABLE evo_s_multisites (id INTEGER PRIMARY KEY)'); +check(C::tableReady(), 'create after missing in same process'); +$c->flushQueryLog(); +check(C::tableReady() && count($c->getQueryLog()) === 0, 'created table warmed'); +echo "ALL TABLE READINESS CHECKS PASSED\n"; +$other = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'), ':memory:', 'evo_', $c->getConfig()); +$keyMethod = new ReflectionMethod(C::class, 'key'); +$originalKey = $keyMethod->invoke(null, $c); +// Normalize the connection name as well: identity must still follow the ephemeral database. + +check($originalKey !== $keyMethod->invoke(null, $other), 'identical in-memory SQLite configurations have isolated cache identities');