From 60c31f969738c94c8e67bebdab40feb2b7355c1c Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Fri, 16 Oct 2026 18:03:49 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Snapshot-Option=20je=20Bereich=20auf=208?= =?UTF-8?q?=20MB=20begrenzen,=20=C3=A4lteste=20Eintr=C3=A4ge=20fallen=20he?= =?UTF-8?q?raus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../Divi/Design/SnapshotStore.php | 27 ++++++++++++++++--- .../Divi/Design/SnapshotStoreTest.php | 26 ++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/Integrations/Divi/Design/SnapshotStore.php b/src/Integrations/Divi/Design/SnapshotStore.php index 5ddff26..3e5a5ca 100644 --- a/src/Integrations/Divi/Design/SnapshotStore.php +++ b/src/Integrations/Divi/Design/SnapshotStore.php @@ -11,8 +11,9 @@ defined('ABSPATH') || exit; /** * Snapshots of the design system (spec §5, G6): one option per area (lit_mcp_snapshots_, never autoloaded), - * newest first, at most LIMIT entries – the oldest falls out. An entry keeps the area's raw inventory as Divi stores - * it plus who made it with which tool and the change log entry. The only way back for colors, variables and presets: + * newest first, at most LIMIT entries and TOTAL_BYTES together – the oldest fall out, the newest always stays. + * An entry keeps the area's raw inventory as Divi stores it plus who made it with which tool and the change log + * entry. The only way back for colors, variables and presets: * Divi 5 keeps no server-side history (formats §7.6). Not final so abilities can be tested with a mock. */ class SnapshotStore @@ -24,6 +25,9 @@ class SnapshotStore /** Spec §5: a larger snapshot refuses the change. */ public const MAX_BYTES = 2000000; + /** Final review I-4: all entries of one area together (sum of "size"). */ + public const TOTAL_BYTES = 8000000; + private const PREFIX = 'lit_mcp_snapshots_'; public static function option(string $area): string @@ -63,13 +67,30 @@ class SnapshotStore 'size' => $size, 'data' => $data, ]); - if (! update_option(self::option($area), array_slice($entries, 0, self::LIMIT), false)) { + if (! update_option(self::option($area), self::trimmed($entries), false)) { throw new RuntimeException(sprintf('snapshot %s could not be stored', $area)); } return $id; } + /** + * The newest LIMIT entries, minus the oldest while all together exceed TOTAL_BYTES; the newest always stays. + * + * @param list $entries Newest first. + * @return list + */ + private static function trimmed(array $entries): array + { + $entries = array_slice($entries, 0, self::LIMIT); + $total = array_sum(array_column($entries, 'size')); + while (count($entries) > 1 && $total > self::TOTAL_BYTES) { + $total -= array_pop($entries)['size']; + } + + return $entries; + } + /** Notes the change log entry of the change the snapshot belongs to (the log entry is written after it). */ public function attachLog(string $area, string $id, ?int $logId): void { diff --git a/tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php b/tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php index 1d30d46..136470f 100644 --- a/tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php +++ b/tests/Unit/Integrations/Divi/Design/SnapshotStoreTest.php @@ -64,6 +64,32 @@ final class SnapshotStoreTest extends TestCase self::assertNull($store->get('presets', $ids[1])); } + /** Final review I-4: the option as a whole stays below TOTAL_BYTES; the oldest entries fall out first. */ + public function testKeepsTheOptionBelowTheTotalSize(): void + { + $store = new SnapshotStore(); + $big = ['x' => str_repeat('a', 1900000)]; + $ids = []; + for ($i = 0; $i < 5; ++$i) { + $ids[] = $store->save('presets', $big + ['n' => $i], 'lit/divi-presets-set'); + } + + $list = $store->list('presets'); + self::assertSame([$ids[4], $ids[3], $ids[2], $ids[1]], array_column($list, 'id')); + self::assertLessThanOrEqual(SnapshotStore::TOTAL_BYTES, array_sum(array_column($list, 'size'))); + } + + public function testOldEntriesOverTheTotalSizeFallOutButTheNewestStays(): void + { + $store = new SnapshotStore(); + $store->save('presets', ['x' => str_repeat('a', 1900000)], 'lit/divi-presets-set'); + $this->options['lit_mcp_snapshots_presets'][0]['size'] = SnapshotStore::TOTAL_BYTES + 1; + + $newest = $store->save('presets', ['n' => 1], 'lit/divi-presets-set'); + + self::assertSame([$newest], array_column($store->list('presets'), 'id')); + } + public function testRefusesSnapshotsAboveTheLimit(): void { $this->expectException(SnapshotTooLarge::class);