From d3edce2bb8d9d09d706a8745afe40b9be5e8871b Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Sun, 9 Aug 2026 01:06:16 -0600 Subject: [PATCH 1/4] Reduce front-end database queries from CMS templates, theme data and request logs Production query stats on a site taking heavy bot traffic showed three hot paths dominating database time, all of them avoidable without giving up any functionality. **CMS templates.** `CmsObject::loadCached()` caches parsed objects, but `Halcyon\Builder::getCached()` validates every warm cache hit by calling `datasource->lastModified()`, so each database-backed template still cost a query per request. `AutoDatasource` already holds a forever-cached, correctly invalidated manifest of the paths in each datasource; paired with the companion Storm change, that manifest now carries modification times, so the lookup is answered without touching the database. Filesystem datasources are untouched and keep resolving mtimes live, so editing templates on disk still takes effect immediately. **Theme data.** `ThemeData::forTheme()` was memoised only in a static array, making it one uncached query per request. The dominant caller is not `{{ theme.* }}` in templates but the asset combiner: `cms.combiner.getCacheKey` is registered for front-end requests only and fires inside `CombineAssets::getCacheKey()`, which runs before the combiner cache is consulted, so a warm combiner cache did not avoid it. The record is now cached following the same pattern as `SettingsModel`, invalidated in `afterSave()` and a new `afterDelete()` (`Theme::removeCustomData()` deletes the record and had no hook). `Theme::resetCache()` also clears the in-memory instances, which it previously left stale for long-running workers. **Request logs.** `system_request_logs` had no index beyond its primary key, so every 404 full-scanned a table that only ever grows -- and since `add()` is what grows it, the cost accelerated on its own. This was 376ms average and by far the largest share of database time. Adds the missing `(url, status_code)` index, matching the columns `RequestLog::add()` looks up. Also adds a composite `(source, path, deleted_at)` index to `cms_theme_templates`, replacing the redundant standalone `source` index -- every datasource query filters on source and path together, but the two independent single-column indexes meant only one could be used. Co-Authored-By: Claude Opus 5 (1M context) --- modules/cms/classes/AutoDatasource.php | 30 +++- modules/cms/classes/Theme.php | 2 + ..._000004_Db_Cms_Theme_Templates_Indexes.php | 22 +++ modules/cms/models/ThemeData.php | 44 ++++- .../cms/tests/classes/AutoDatasourceTest.php | 95 ++++++++++- modules/cms/tests/classes/ThemeTest.php | 11 ++ .../fixtures/themes/themedata/theme.yaml | 9 + modules/cms/tests/models/ThemeDataTest.php | 154 ++++++++++++++++++ ..._000032_Db_System_Request_Logs_Indexes.php | 18 ++ .../system/tests/models/RequestLogTest.php | 91 +++++++++++ 10 files changed, 473 insertions(+), 3 deletions(-) create mode 100644 modules/cms/database/migrations/2026_08_09_000004_Db_Cms_Theme_Templates_Indexes.php create mode 100644 modules/cms/tests/fixtures/themes/themedata/theme.yaml create mode 100644 modules/cms/tests/models/ThemeDataTest.php create mode 100644 modules/system/database/migrations/2026_08_09_000032_Db_System_Request_Logs_Indexes.php create mode 100644 modules/system/tests/models/RequestLogTest.php diff --git a/modules/cms/classes/AutoDatasource.php b/modules/cms/classes/AutoDatasource.php index 47d2238046..63852ba8ba 100644 --- a/modules/cms/classes/AutoDatasource.php +++ b/modules/cms/classes/AutoDatasource.php @@ -284,6 +284,24 @@ protected function getDatasourceForPath(string $path): DatasourceInterface return $this->datasources[$datasourceIndex]; } + /** + * Get the path cache entry for the provided path from the first datasource that reports it + * + * @return mixed The datasource's entry for this path, or null if no datasource reports it. + * Database datasources report a last modified timestamp, other datasources + * report `true`, and paths marked as deleted report `false`. + */ + protected function getPathCacheEntry(string $path): mixed + { + foreach ($this->pathCache as $paths) { + if (isset($paths[$path])) { + return $paths[$path]; + } + } + + return null; + } + /** * Get all valid paths for the provided directory, removing any paths marked as deleted * @@ -512,7 +530,17 @@ public function delete(string $dirName, string $fileName, string $extension): bo */ public function lastModified(string $dirName, string $fileName, string $extension): ?int { - return $this->getDatasourceForPath($this->makeFilePath($dirName, $fileName, $extension))->lastModified($dirName, $fileName, $extension); + $path = $this->makeFilePath($dirName, $fileName, $extension); + + // Database datasources record modification times in the path cache, which lets the + // Halcyon cache validate itself without querying the database on every request. + // Anything else (filesystem sources report `true`, deleted paths report `false`) + // falls through to the datasource so its modification time stays live. + if (!$this->singleDatasourceMode && is_int($mtime = $this->getPathCacheEntry($path))) { + return $mtime; + } + + return $this->getDatasourceForPath($path)->lastModified($dirName, $fileName, $extension); } /** diff --git a/modules/cms/classes/Theme.php b/modules/cms/classes/Theme.php index 98f63d00df..738c6efd37 100644 --- a/modules/cms/classes/Theme.php +++ b/modules/cms/classes/Theme.php @@ -563,6 +563,8 @@ public static function resetCache(bool $memoryOnly = false): void self::$activeThemeCache = false; self::$editThemeCache = false; + ThemeData::flushCache(); + // Sometimes it may be desired to only clear the local cache of the active / edit themes instead of the persistent cache if (!$memoryOnly) { Cache::forget(self::ACTIVE_KEY); diff --git a/modules/cms/database/migrations/2026_08_09_000004_Db_Cms_Theme_Templates_Indexes.php b/modules/cms/database/migrations/2026_08_09_000004_Db_Cms_Theme_Templates_Indexes.php new file mode 100644 index 0000000000..d584768e02 --- /dev/null +++ b/modules/cms/database/migrations/2026_08_09_000004_Db_Cms_Theme_Templates_Indexes.php @@ -0,0 +1,22 @@ +index(['source', 'path', 'deleted_at']); + $table->dropIndex(['source']); + }); + } + + public function down() + { + Schema::table('cms_theme_templates', function ($table) { + $table->index(['source']); + $table->dropIndex(['source', 'path', 'deleted_at']); + }); + } +}; diff --git a/modules/cms/models/ThemeData.php b/modules/cms/models/ThemeData.php index 03015851ff..687fb91536 100644 --- a/modules/cms/models/ThemeData.php +++ b/modules/cms/models/ThemeData.php @@ -4,6 +4,7 @@ use Cms\Classes\Theme as CmsTheme; use Exception; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Lang; use System\Classes\CombineAssets; use System\Models\File; @@ -54,6 +55,11 @@ class ThemeData extends Model */ protected static $instances = []; + /** + * @var int The number of minutes the theme data is cached for. + */ + protected static $cacheTtl = 1440; + /** * Before saving the model, strip dynamic attributes applied from config. * @return void @@ -76,6 +82,8 @@ public function beforeSave() */ public function afterSave() { + static::flushCache($this->theme); + try { CombineAssets::resetCache(); } @@ -83,6 +91,36 @@ public function afterSave() } } + /** + * Clear the cache after deleting so that the record isn't served from the cache. + */ + public function afterDelete() + { + static::flushCache($this->theme); + } + + /** + * Returns the cache key used to store the data for the provided theme directory. + */ + public static function getCacheKey(string $dirName): string + { + return 'cms::theme.data.' . $dirName; + } + + /** + * Removes both the persistent and in memory cache of the provided theme directory's data. + */ + public static function flushCache(?string $dirName = null): void + { + if (is_null($dirName)) { + self::$instances = []; + return; + } + + Cache::forget(static::getCacheKey($dirName)); + unset(self::$instances[$dirName]); + } + /** * Returns a cached version of this model, based on a Theme object. * @param $theme Cms\Classes\Theme @@ -96,7 +134,11 @@ public static function forTheme($theme) } try { - $themeData = self::firstOrCreate(['theme' => $dirName]); + // The record is cached rather than queried on every request; it is invalidated + // by afterSave() / afterDelete(), which also covers the initial creation below. + $themeData = self::where('theme', $dirName) + ->remember(self::$cacheTtl, self::getCacheKey($dirName)) + ->first() ?: self::create(['theme' => $dirName]); } catch (Exception $ex) { // Database failed diff --git a/modules/cms/tests/classes/AutoDatasourceTest.php b/modules/cms/tests/classes/AutoDatasourceTest.php index b481c2afc9..b61fc66c28 100644 --- a/modules/cms/tests/classes/AutoDatasourceTest.php +++ b/modules/cms/tests/classes/AutoDatasourceTest.php @@ -2,11 +2,14 @@ namespace Cms\Tests\Classes; +use Exception; use System\Tests\Bootstrap\PluginTestCase; use Cms\Classes\AutoDatasource; +use Winter\Storm\Database\MemoryCache; use Winter\Storm\Database\Model; use Winter\Storm\Halcyon\Datasource\DbDatasource; use Winter\Storm\Halcyon\Datasource\FileDatasource; +use Winter\Storm\Support\Facades\DB; class CmsThemeTemplateFixture extends Model { @@ -58,7 +61,8 @@ public function setUp(): void 'source' => 'test', 'path' => 'partials/subdir/test.htm', 'content' => 'AutoDatasource partials/subdir/test.htm', - 'file_size' => 39 + 'file_size' => 39, + 'updated_at' => '2019-06-01 12:00:00' ]); $this->fixtures[] = CmsThemeTemplateFixture::create([ @@ -118,4 +122,93 @@ public function testSelect() // One filesystem partial should be marked deleted in database $this->assertArrayNotHasKey('nesting/level2.htm', $results); } + + public function testPathCacheValueShapes() + { + $pathCache = self::getProtectedProperty($this->datasource, 'pathCache'); + + // Database records report their last modified time, deleted records report false + $this->assertIsInt($pathCache[0]['partials/subdir/test.htm']); + $this->assertEquals( + strtotime('2019-06-01 12:00:00'), + $pathCache[0]['partials/subdir/test.htm'] + ); + $this->assertFalse($pathCache[0]['partials/nesting/level2.htm']); + + // Filesystem records continue to report true so that their mtime is resolved live + $this->assertTrue($pathCache[1]['partials/layout-partial.htm']); + } + + public function testLastModifiedIsServedFromPathCacheWithoutQuerying() + { + // The duplicate query cache would otherwise mask a query issued by this call + MemoryCache::instance()->flush(); + + DB::connection()->flushQueryLog(); + DB::connection()->enableQueryLog(); + + $mtime = $this->datasource->lastModified('partials', 'subdir/test', 'htm'); + + $queries = DB::connection()->getQueryLog(); + DB::connection()->disableQueryLog(); + + $this->assertEquals(strtotime('2019-06-01 12:00:00'), $mtime); + $this->assertCount(0, $queries, 'lastModified() should not query the database'); + } + + public function testLastModifiedFallsBackToFilesystemDatasource() + { + $path = base_path('modules/system/tests/fixtures/themes/test/partials/layout-partial.htm'); + + $this->assertEquals( + filemtime($path), + $this->datasource->lastModified('partials', 'layout-partial', 'htm') + ); + } + + public function testLastModifiedIsStableForNullUpdatedAt() + { + $this->fixtures[] = CmsThemeTemplateFixture::create([ + 'source' => 'test', + 'path' => 'partials/no-timestamp.htm', + 'content' => 'AutoDatasource partials/no-timestamp.htm', + 'file_size' => 40, + 'updated_at' => null, + ]); + + $this->datasource->populateCache(true); + + $pathCache = self::getProtectedProperty($this->datasource, 'pathCache'); + $cached = $pathCache[0]['partials/no-timestamp.htm']; + + // Records without an updated_at previously resolved to "now" on every call, which + // busted the Halcyon cache on every request. The value is now resolved once, when + // the path cache is built, and stays fixed until the path cache is rebuilt. + $this->assertIsInt($cached); + $this->assertEquals($cached, $this->datasource->lastModified('partials', 'no-timestamp', 'htm')); + } + + public function testLastModifiedThrowsForDeletedPath() + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('partials/nesting/level2.htm is deleted'); + + $this->datasource->lastModified('partials', 'nesting/level2', 'htm'); + } + + public function testLastModifiedReflectsUpdatesMadeThroughTheDatasource() + { + $before = $this->datasource->lastModified('partials', 'subdir/test', 'htm'); + + $this->datasource->update('partials', 'subdir/test', 'htm', 'Updated content'); + + $after = $this->datasource->lastModified('partials', 'subdir/test', 'htm'); + + // Editing a template must take effect immediately, without clearing the cache + $this->assertGreaterThan($before, $after); + $this->assertEquals( + 'Updated content', + $this->datasource->selectOne('partials', 'subdir/test', 'htm')['content'] + ); + } } diff --git a/modules/cms/tests/classes/ThemeTest.php b/modules/cms/tests/classes/ThemeTest.php index e664ba7294..3e904e8811 100644 --- a/modules/cms/tests/classes/ThemeTest.php +++ b/modules/cms/tests/classes/ThemeTest.php @@ -4,6 +4,7 @@ use System\Tests\Bootstrap\TestCase; use Cms\Classes\Theme; +use Cms\Models\ThemeData; use Config; use Event; use Winter\Storm\Exception\ApplicationException; @@ -163,4 +164,14 @@ public function testLoadRejectsPathTraversal() Theme::load('../../etc'); } + + public function testResetCacheClearsThemeData() + { + $themeData = new ThemeData(['theme' => 'test']); + self::setProtectedProperty($themeData, 'instances', ['test' => $themeData]); + + Theme::resetCache(); + + $this->assertEmpty(self::getProtectedProperty($themeData, 'instances')); + } } diff --git a/modules/cms/tests/fixtures/themes/themedata/theme.yaml b/modules/cms/tests/fixtures/themes/themedata/theme.yaml new file mode 100644 index 0000000000..90d9ee661d --- /dev/null +++ b/modules/cms/tests/fixtures/themes/themedata/theme.yaml @@ -0,0 +1,9 @@ +name: ThemeData Test +description: Theme with customization fields, used to test Cms\Models\ThemeData + +form: + fields: + site_name: + label: Site name + type: text + default: Winter diff --git a/modules/cms/tests/models/ThemeDataTest.php b/modules/cms/tests/models/ThemeDataTest.php new file mode 100644 index 0000000000..ae90acaaaf --- /dev/null +++ b/modules/cms/tests/models/ThemeDataTest.php @@ -0,0 +1,154 @@ +theme = Theme::load('themedata'); + } + + public function tearDown(): void + { + ThemeData::flushCache('themedata'); + + parent::tearDown(); + } + + /** + * Counts the queries made against the theme data table while running the callback. + */ + protected function countQueries(callable $callback): int + { + // Identical queries are deduplicated in memory for the lifetime of a request, which + // would otherwise hide the query this cache is meant to avoid on the next request + MemoryCache::instance()->flush(); + + DB::connection()->flushQueryLog(); + DB::connection()->enableQueryLog(); + + $callback(); + + $queries = DB::connection()->getQueryLog(); + DB::connection()->disableQueryLog(); + + return count(array_filter($queries, function ($query) { + return str_contains($query['query'], 'cms_theme_data'); + })); + } + + /** + * Ensures the record exists and the persistent cache is populated, then drops only the + * in memory cache, simulating the state at the start of a subsequent request. + */ + protected function primeCache(): void + { + ThemeData::forTheme($this->theme); + ThemeData::flushCache(); + + ThemeData::forTheme($this->theme); + ThemeData::flushCache(); + } + + public function testForThemeIsCachedAcrossRequests() + { + $this->primeCache(); + + $queries = $this->countQueries(function () { + ThemeData::forTheme($this->theme); + }); + + $this->assertEquals(0, $queries, 'Theme data should be served from the cache'); + } + + public function testForThemeCreatesRecordOnlyOnce() + { + ThemeData::forTheme($this->theme); + ThemeData::flushCache(); + ThemeData::forTheme($this->theme); + ThemeData::flushCache(); + ThemeData::forTheme($this->theme); + + $this->assertEquals(1, ThemeData::where('theme', 'themedata')->count()); + } + + public function testDefaultValuesAreAppliedToCachedRecords() + { + $this->primeCache(); + + // Defaults are applied by afterFetch(), which must still run for cached rows + $this->assertEquals('Winter', ThemeData::forTheme($this->theme)->site_name); + } + + public function testAfterSaveInvalidatesCache() + { + $this->primeCache(); + + $themeData = ThemeData::forTheme($this->theme); + $themeData->site_name = 'Updated'; + $themeData->save(); + + // Only drop the in memory cache; the persistent cache must have been invalidated + // by afterSave(), otherwise theme customizations would not take effect + ThemeData::flushCache(); + + $this->assertEquals('Updated', ThemeData::forTheme($this->theme)->site_name); + } + + public function testAfterDeleteInvalidatesCache() + { + $this->primeCache(); + + $themeData = ThemeData::forTheme($this->theme); + $themeData->site_name = 'Updated'; + $themeData->save(); + + $this->theme->removeCustomData(); + ThemeData::flushCache(); + + // A new record should be created rather than the deleted one being served + $this->assertNull(ThemeData::forTheme($this->theme)->site_name); + $this->assertEquals(1, ThemeData::where('theme', 'themedata')->count()); + } + + public function testDynamicAttributesSurviveTheCache() + { + $themeData = ThemeData::forTheme($this->theme); + $themeData->site_name = 'From cache'; + $themeData->save(); + + ThemeData::flushCache(); + ThemeData::forTheme($this->theme); + ThemeData::flushCache(); + + $queries = $this->countQueries(function () { + // Dynamic attributes live in the jsonable `data` column and are expanded by + // afterFetch(), which runs during hydration rather than during the query + $this->assertEquals('From cache', ThemeData::forTheme($this->theme)->site_name); + }); + + $this->assertEquals(0, $queries); + } +} diff --git a/modules/system/database/migrations/2026_08_09_000032_Db_System_Request_Logs_Indexes.php b/modules/system/database/migrations/2026_08_09_000032_Db_System_Request_Logs_Indexes.php new file mode 100644 index 0000000000..5b5db085b0 --- /dev/null +++ b/modules/system/database/migrations/2026_08_09_000032_Db_System_Request_Logs_Indexes.php @@ -0,0 +1,18 @@ +index(['url', 'status_code']); + }); + } + + public function down() + { + Schema::table('system_request_logs', function ($table) { + $table->dropIndex(['url', 'status_code']); + }); + } +}; diff --git a/modules/system/tests/models/RequestLogTest.php b/modules/system/tests/models/RequestLogTest.php new file mode 100644 index 0000000000..5d37b5d3b7 --- /dev/null +++ b/modules/system/tests/models/RequestLogTest.php @@ -0,0 +1,91 @@ +getDriverName()) { + case 'sqlite': + $indexes = $connection->select('PRAGMA index_list(' . $connection->getTablePrefix() . $table . ')'); + return array_column(array_map(fn ($row) => (array) $row, $indexes), 'name'); + + case 'mysql': + $indexes = $connection->select('SHOW INDEX FROM ' . $connection->getTablePrefix() . $table); + return array_column(array_map(fn ($row) => (array) $row, $indexes), 'Key_name'); + + case 'pgsql': + $indexes = $connection->select( + 'SELECT indexname AS name FROM pg_indexes WHERE tablename = ?', + [$connection->getTablePrefix() . $table] + ); + return array_column(array_map(fn ($row) => (array) $row, $indexes), 'name'); + } + + $this->markTestSkipped('Unsupported database driver: ' . $connection->getDriverName()); + } + + public function testUrlAndStatusCodeAreIndexed() + { + // Without this index every logged request performs a full table scan of a table + // that only ever grows + $this->assertContains( + 'system_request_logs_url_status_code_index', + $this->getIndexNames('system_request_logs') + ); + } + + public function testAddCreatesRecord() + { + $record = RequestLog::add(404); + + $this->assertNotNull($record); + $this->assertEquals(1, $record->count); + $this->assertEquals(404, $record->status_code); + $this->assertEquals(1, RequestLog::count()); + } + + public function testAddIncrementsExistingRecord() + { + RequestLog::add(404); + RequestLog::add(404); + RequestLog::add(404); + + $this->assertEquals(1, RequestLog::count()); + $this->assertEquals(3, RequestLog::first()->count); + } + + public function testAddSeparatesStatusCodes() + { + RequestLog::add(404); + RequestLog::add(500); + + $this->assertEquals(2, RequestLog::count()); + } + + public function testAddRespectsLogRequestsSetting() + { + LogSetting::set('log_requests', false); + + $this->assertNull(RequestLog::add(404)); + $this->assertEquals(0, RequestLog::count()); + } +} From 2276fbcaf005f1ed140c15bc1b0043036f997206 Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Mon, 10 Aug 2026 02:11:57 -0600 Subject: [PATCH 2/4] Correct an overstated comment about nullable template timestamps Freezing the value in the path cache makes lastModified() report a consistent result, but selectOne() still resolves a null updated_at live, so the two disagree and the Halcyon cache is still busted on every request for those records. The assertions were already correct; only the comment overstated what they prove. Co-Authored-By: Claude Opus 5 (1M context) --- modules/cms/tests/classes/AutoDatasourceTest.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/cms/tests/classes/AutoDatasourceTest.php b/modules/cms/tests/classes/AutoDatasourceTest.php index b61fc66c28..7e970f377c 100644 --- a/modules/cms/tests/classes/AutoDatasourceTest.php +++ b/modules/cms/tests/classes/AutoDatasourceTest.php @@ -181,9 +181,12 @@ public function testLastModifiedIsStableForNullUpdatedAt() $pathCache = self::getProtectedProperty($this->datasource, 'pathCache'); $cached = $pathCache[0]['partials/no-timestamp.htm']; - // Records without an updated_at previously resolved to "now" on every call, which - // busted the Halcyon cache on every request. The value is now resolved once, when - // the path cache is built, and stays fixed until the path cache is rebuilt. + // updated_at is nullable, and Carbon::parse(null) resolves to "now". The value is + // resolved once, when the path cache is built, so lastModified() reports it + // consistently rather than returning a different result on every call. + // Note this does not make the Halcyon cache usable for such records: selectOne() + // still resolves their mtime live, so the two disagree and the cache is busted on + // every request. That is pre-existing and not addressed here. $this->assertIsInt($cached); $this->assertEquals($cached, $this->datasource->lastModified('partials', 'no-timestamp', 'htm')); } From b6007598adedb939c36e6528cb86a987042514df Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Mon, 10 Aug 2026 02:39:16 -0600 Subject: [PATCH 3/4] Cover template records with an epoch modification time The path cache carries modification times for database-backed templates, but every consumer of it tests the value for truthiness to decide whether a path can be handled. A live record whose updated_at is the Unix epoch produces a timestamp of 0, which made it read as deleted: absent from select() listings and null from selectOne(). Covers the AutoDatasource side of the accompanying Storm fix. Co-Authored-By: Claude Opus 5 (1M context) --- .../cms/tests/classes/AutoDatasourceTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/modules/cms/tests/classes/AutoDatasourceTest.php b/modules/cms/tests/classes/AutoDatasourceTest.php index 7e970f377c..c04b0a3545 100644 --- a/modules/cms/tests/classes/AutoDatasourceTest.php +++ b/modules/cms/tests/classes/AutoDatasourceTest.php @@ -191,6 +191,32 @@ public function testLastModifiedIsStableForNullUpdatedAt() $this->assertEquals($cached, $this->datasource->lastModified('partials', 'no-timestamp', 'htm')); } + public function testRecordsWithAnEpochTimestampRemainAvailable() + { + $this->fixtures[] = CmsThemeTemplateFixture::create([ + 'source' => 'test', + 'path' => 'partials/epoch.htm', + 'content' => 'AutoDatasource partials/epoch.htm', + 'file_size' => 33, + 'updated_at' => '1970-01-01 00:00:00', + ]); + + $this->datasource->populateCache(true); + + // The path cache carries modification times, but every consumer of it tests the + // value for truthiness. A timestamp of 0 must therefore not be stored as-is, or + // this live record would read as deleted and disappear. + $listed = collect($this->datasource->select('partials', ['columns' => ['fileName']])) + ->pluck('fileName') + ->all(); + + $this->assertContains('epoch.htm', $listed); + $this->assertSame( + 'AutoDatasource partials/epoch.htm', + $this->datasource->selectOne('partials', 'epoch', 'htm')['content'] + ); + } + public function testLastModifiedThrowsForDeletedPath() { $this->expectException(Exception::class); From b13eab1774fbc4a45ade2e7edf51224971acacba Mon Sep 17 00:00:00 2001 From: Luke Towers Date: Mon, 10 Aug 2026 02:42:48 -0600 Subject: [PATCH 4/4] Revert "Cover template records with an epoch modification time" This reverts commit b6007598adedb939c36e6528cb86a987042514df. --- .../cms/tests/classes/AutoDatasourceTest.php | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/modules/cms/tests/classes/AutoDatasourceTest.php b/modules/cms/tests/classes/AutoDatasourceTest.php index c04b0a3545..7e970f377c 100644 --- a/modules/cms/tests/classes/AutoDatasourceTest.php +++ b/modules/cms/tests/classes/AutoDatasourceTest.php @@ -191,32 +191,6 @@ public function testLastModifiedIsStableForNullUpdatedAt() $this->assertEquals($cached, $this->datasource->lastModified('partials', 'no-timestamp', 'htm')); } - public function testRecordsWithAnEpochTimestampRemainAvailable() - { - $this->fixtures[] = CmsThemeTemplateFixture::create([ - 'source' => 'test', - 'path' => 'partials/epoch.htm', - 'content' => 'AutoDatasource partials/epoch.htm', - 'file_size' => 33, - 'updated_at' => '1970-01-01 00:00:00', - ]); - - $this->datasource->populateCache(true); - - // The path cache carries modification times, but every consumer of it tests the - // value for truthiness. A timestamp of 0 must therefore not be stored as-is, or - // this live record would read as deleted and disappear. - $listed = collect($this->datasource->select('partials', ['columns' => ['fileName']])) - ->pluck('fileName') - ->all(); - - $this->assertContains('epoch.htm', $listed); - $this->assertSame( - 'AutoDatasource partials/epoch.htm', - $this->datasource->selectOne('partials', 'epoch', 'htm')['content'] - ); - } - public function testLastModifiedThrowsForDeletedPath() { $this->expectException(Exception::class);